pax_global_header00006660000000000000000000000064146130341330014510gustar00rootroot0000000000000052 comment=6095d9dc710a8901a4e0b7be92f59486576a2c81 python-odmantic-1.0.2/000077500000000000000000000000001461303413300146255ustar00rootroot00000000000000python-odmantic-1.0.2/.codecov.yml000066400000000000000000000001041461303413300170430ustar00rootroot00000000000000codecov: require_ci_to_pass: yes notify: after_n_builds: 14 python-odmantic-1.0.2/.darglint000066400000000000000000000001441461303413300164310ustar00rootroot00000000000000[darglint] # Allow one line docstrings without arg spec strictness = short docstring_style = google python-odmantic-1.0.2/.devcontainer/000077500000000000000000000000001461303413300173645ustar00rootroot00000000000000python-odmantic-1.0.2/.devcontainer/Dockerfile000066400000000000000000000010171461303413300213550ustar00rootroot00000000000000FROM 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.json000066400000000000000000000020571461303413300227440ustar00rootroot00000000000000// 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.yml000066400000000000000000000010031461303413300230130ustar00rootroot00000000000000version: "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/.gitattributes000066400000000000000000000000521461303413300175150ustar00rootroot00000000000000*.png filter=lfs diff=lfs merge=lfs -text python-odmantic-1.0.2/.github/000077500000000000000000000000001461303413300161655ustar00rootroot00000000000000python-odmantic-1.0.2/.github/ISSUE_TEMPLATE/000077500000000000000000000000001461303413300203505ustar00rootroot00000000000000python-odmantic-1.0.2/.github/ISSUE_TEMPLATE/bug.md000066400000000000000000000011371461303413300214510ustar00rootroot00000000000000--- 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.md000066400000000000000000000012261461303413300223260ustar00rootroot00000000000000--- 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.yml000066400000000000000000000022551461303413300204560ustar00rootroot00000000000000version: "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.yml000066400000000000000000000003071461303413300210150ustar00rootroot00000000000000version: 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.jinja2000066400000000000000000000001431461303413300221640ustar00rootroot00000000000000- {{pr.title}} ([#{{pr.number}}]({{pr.html_url}}) by [@{{pr.user.login}}]({{pr.user.html_url}})) python-odmantic-1.0.2/.github/release.py000066400000000000000000000115231461303413300201610ustar00rootroot00000000000000import 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/000077500000000000000000000000001461303413300202225ustar00rootroot00000000000000python-odmantic-1.0.2/.github/workflows/ci.yml000066400000000000000000000131721461303413300213440ustar00rootroot00000000000000name: 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.yml000066400000000000000000000021221461303413300225300ustar00rootroot00000000000000name: 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.yml000066400000000000000000000023021461303413300233510ustar00rootroot00000000000000name: 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.yml000066400000000000000000000013761461303413300217040ustar00rootroot00000000000000name: 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.yml000066400000000000000000000013201461303413300236430ustar00rootroot00000000000000name: 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.yml000066400000000000000000000010251461303413300223630ustar00rootroot00000000000000name: 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/.gitignore000066400000000000000000000023641461303413300166220ustar00rootroot00000000000000# 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/.gitmodules000066400000000000000000000002021461303413300167740ustar00rootroot00000000000000[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/000077500000000000000000000000001461303413300212625ustar00rootroot00000000000000python-odmantic-1.0.2/.pre-commit-config.yaml000066400000000000000000000027411461303413300211120ustar00rootroot00000000000000# 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/.prettierignore000066400000000000000000000000321461303413300176630ustar00rootroot00000000000000docs/**/*.md CHANGELOG.md python-odmantic-1.0.2/.vscode/000077500000000000000000000000001461303413300161665ustar00rootroot00000000000000python-odmantic-1.0.2/.vscode/extensions.json000066400000000000000000000004121461303413300212550ustar00rootroot00000000000000{ "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.json000066400000000000000000000010541461303413300203330ustar00rootroot00000000000000{ // 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.json000066400000000000000000000007641461303413300207300ustar00rootroot00000000000000{ "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.md000066400000000000000000000527571461303413300164560ustar00rootroot00000000000000# 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.md000066400000000000000000000121471461303413300174310ustar00rootroot00000000000000# 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.md000066400000000000000000000001651461303413300170600ustar00rootroot00000000000000Please see the [contributing guidelines](https://art049.github.io/odmantic/contributing/) on the documentation site. python-odmantic-1.0.2/LICENSE000066400000000000000000000013511461303413300156320ustar00rootroot00000000000000ISC 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.md000066400000000000000000000233341461303413300161110ustar00rootroot00000000000000

ODMantic

[![build](https://github.com/art049/odmantic/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/art049/odmantic/actions/workflows/ci.yml) [![coverage](https://codecov.io/gh/art049/odmantic/branch/master/graph/badge.svg?token=3NYZK14STZ)](https://codecov.io/gh/art049/odmantic) ![python-3.8-3.9-3.10-3.11-3.12](https://img.shields.io/badge/python-3.8%20|%203.9%20|%203.10%20|%203.11%20|%203.12-informational.svg) [![Package version](https://img.shields.io/pypi/v/odmantic?color=%2334D058&label=pypi)](https://pypi.org/project/odmantic) [![CodSpeed](https://img.shields.io/endpoint?url=https://codspeed.io/badge.json)](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.md000066400000000000000000000013711461303413300164200ustar00rootroot00000000000000# 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.yml000066400000000000000000000046711461303413300171220ustar00rootroot00000000000000# 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.yml000066400000000000000000000001501461303413300174510ustar00rootroot00000000000000version: 2 groups: dev-dependencies: dependency-type: development applies-to: version-updates python-odmantic-1.0.2/docs/000077500000000000000000000000001461303413300155555ustar00rootroot00000000000000python-odmantic-1.0.2/docs/__init__.py000066400000000000000000000000151461303413300176620ustar00rootroot00000000000000# Helps mypy python-odmantic-1.0.2/docs/api_reference/000077500000000000000000000000001461303413300203445ustar00rootroot00000000000000python-odmantic-1.0.2/docs/api_reference/bson.md000066400000000000000000000016441461303413300216340ustar00rootroot00000000000000This 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.md000066400000000000000000000000421461303413300221270ustar00rootroot00000000000000::: odmantic.config.ODMConfigDict python-odmantic-1.0.2/docs/api_reference/engine.md000066400000000000000000000001751461303413300221360ustar00rootroot00000000000000::: odmantic.engine.AIOEngine ::: odmantic.engine.AIOCursor ::: odmantic.engine.SyncEngine ::: odmantic.engine.SyncCursor python-odmantic-1.0.2/docs/api_reference/exceptions.md000066400000000000000000000004141461303413300230460ustar00rootroot00000000000000::: 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.md000066400000000000000000000000311461303413300217430ustar00rootroot00000000000000::: odmantic.field.Field python-odmantic-1.0.2/docs/api_reference/index.md000066400000000000000000000000311461303413300217670ustar00rootroot00000000000000::: odmantic.index.Index python-odmantic-1.0.2/docs/api_reference/model.md000066400000000000000000000005251461303413300217700ustar00rootroot00000000000000::: 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.md000066400000000000000000000007201461303413300220320ustar00rootroot00000000000000::: 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.md000066400000000000000000000000411461303413300226170ustar00rootroot00000000000000::: odmantic.reference.Reference python-odmantic-1.0.2/docs/api_reference/session.md000066400000000000000000000003251461303413300223510ustar00rootroot00000000000000 ::: 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/000077500000000000000000000000001461303413300223425ustar00rootroot00000000000000python-odmantic-1.0.2/docs/api_reference/templates/python/000077500000000000000000000000001461303413300236635ustar00rootroot00000000000000python-odmantic-1.0.2/docs/api_reference/templates/python/material/000077500000000000000000000000001461303413300254615ustar00rootroot00000000000000python-odmantic-1.0.2/docs/api_reference/templates/python/material/properties.html000066400000000000000000000004301461303413300305400ustar00rootroot00000000000000{% if properties %} {% for property in properties %} {% if property != "pydantic-model" %} {{ property }} {% endif %} {% endfor %} {% endif %} python-odmantic-1.0.2/docs/changelog.md000077700000000000000000000000001461303413300220452../CHANGELOG.mdustar00rootroot00000000000000python-odmantic-1.0.2/docs/contributing.md000066400000000000000000000125401461303413300206100ustar00rootroot00000000000000# 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
coverage pre-commit mypy: checked Code style: black Gitter
### 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/000077500000000000000000000000001461303413300163455ustar00rootroot00000000000000python-odmantic-1.0.2/docs/css/extra.css000066400000000000000000000000521461303413300201770ustar00rootroot00000000000000code { --md-code-fg-color: #4cae4fbb; } python-odmantic-1.0.2/docs/engine.md000066400000000000000000000302221461303413300173430ustar00rootroot00000000000000# 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/000077500000000000000000000000001461303413300202425ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/__init__.py000066400000000000000000000000001461303413300223410ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/engine/000077500000000000000000000000001461303413300215075ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/engine/__init__.py000066400000000000000000000000151461303413300236140ustar00rootroot00000000000000# Helps mypy python-odmantic-1.0.2/docs/examples_src/engine/async/000077500000000000000000000000001461303413300226245ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/engine/async/__init__.py000066400000000000000000000000151461303413300247310ustar00rootroot00000000000000# Helps mypy python-odmantic-1.0.2/docs/examples_src/engine/async/count.py000066400000000000000000000005621461303413300243310ustar00rootroot00000000000000from 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.py000066400000000000000000000006031461303413300244400ustar00rootroot00000000000000from 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.py000066400000000000000000000003151461303413300244370ustar00rootroot00000000000000from 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.py000066400000000000000000000003071461303413300263270ustar00rootroot00000000000000from 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.py000066400000000000000000000005061461303413300263330ustar00rootroot00000000000000from 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.py000066400000000000000000000005431461303413300254560ustar00rootroot00000000000000from 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.py000066400000000000000000000006061461303413300261320ustar00rootroot00000000000000from 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.py000066400000000000000000000011341461303413300305400ustar00rootroot00000000000000from 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.py000066400000000000000000000014171461303413300314340ustar00rootroot00000000000000from 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.py000066400000000000000000000010211461303413300270650ustar00rootroot00000000000000from 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.py000066400000000000000000000003171461303413300244740ustar00rootroot00000000000000from 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.py000066400000000000000000000003441461303413300264020ustar00rootroot00000000000000from 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.py000066400000000000000000000010021461303413300267230ustar00rootroot00000000000000from 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.py000066400000000000000000000010461461303413300275750ustar00rootroot00000000000000from 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.py000066400000000000000000000011351461303413300303110ustar00rootroot00000000000000from 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.py000066400000000000000000000004101461303413300244530ustar00rootroot00000000000000from 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/000077500000000000000000000000001461303413300224635ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/engine/sync/__init__.py000066400000000000000000000000151461303413300245700ustar00rootroot00000000000000# Helps mypy python-odmantic-1.0.2/docs/examples_src/engine/sync/count.py000066400000000000000000000005421461303413300241660ustar00rootroot00000000000000from 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.py000066400000000000000000000005711461303413300243030ustar00rootroot00000000000000from 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.py000066400000000000000000000003031461303413300242730ustar00rootroot00000000000000from 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.py000066400000000000000000000002571461303413300261720ustar00rootroot00000000000000from 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.py000066400000000000000000000005021461303413300261660ustar00rootroot00000000000000from 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.py000066400000000000000000000005451461303413300253170ustar00rootroot00000000000000from 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.py000066400000000000000000000005741461303413300257750ustar00rootroot00000000000000from 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.py000066400000000000000000000011221461303413300303740ustar00rootroot00000000000000from 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.py000066400000000000000000000014051461303413300312700ustar00rootroot00000000000000from 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.py000066400000000000000000000010011461303413300267220ustar00rootroot00000000000000from 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.py000066400000000000000000000003131461303413300243270ustar00rootroot00000000000000from 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.py000066400000000000000000000003401461303413300262350ustar00rootroot00000000000000from 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.py000066400000000000000000000007621461303413300265760ustar00rootroot00000000000000from 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.py000066400000000000000000000007761461303413300274450ustar00rootroot00000000000000from 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.py000066400000000000000000000010571461303413300301530ustar00rootroot00000000000000from 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.py000066400000000000000000000003761461303413300243250ustar00rootroot00000000000000from 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/000077500000000000000000000000001461303413300215105ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/fields/__init__.py000066400000000000000000000000151461303413300236150ustar00rootroot00000000000000# Helps mypy python-odmantic-1.0.2/docs/examples_src/fields/async/000077500000000000000000000000001461303413300226255ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/fields/async/custom_key_name.py000066400000000000000000000002551461303413300263630ustar00rootroot00000000000000from 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.py000066400000000000000000000003741461303413300274230ustar00rootroot00000000000000from 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.py000066400000000000000000000002661461303413300257660ustar00rootroot00000000000000from 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.py000066400000000000000000000007121461303413300256500ustar00rootroot00000000000000from 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.py000066400000000000000000000010611461303413300250450ustar00rootroot00000000000000from 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.py000066400000000000000000000011041461303413300250730ustar00rootroot00000000000000from 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.py000066400000000000000000000011161461303413300252540ustar00rootroot00000000000000from 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.py000066400000000000000000000020701461303413300273510ustar00rootroot00000000000000from 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.py000066400000000000000000000025531461303413300267740ustar00rootroot00000000000000from 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.py000066400000000000000000000003051461303413300247000ustar00rootroot00000000000000from 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.py000066400000000000000000000003411461303413300260430ustar00rootroot00000000000000from 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.py000066400000000000000000000007561461303413300230360ustar00rootroot00000000000000from 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.py000066400000000000000000000002171461303413300260460ustar00rootroot00000000000000from 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.py000066400000000000000000000003001461303413300260400ustar00rootroot00000000000000from 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.py000066400000000000000000000004001461303413300236370ustar00rootroot00000000000000from 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.py000066400000000000000000000002571461303413300237130ustar00rootroot00000000000000from 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/000077500000000000000000000000001461303413300224645ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/fields/sync/custom_key_name.py000066400000000000000000000002511461303413300262160ustar00rootroot00000000000000from 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.py000066400000000000000000000003701461303413300272560ustar00rootroot00000000000000from 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.py000066400000000000000000000002621461303413300256210ustar00rootroot00000000000000from 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.py000066400000000000000000000006721461303413300255140ustar00rootroot00000000000000from 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.py000066400000000000000000000003601461303413300232110ustar00rootroot00000000000000from 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.py000066400000000000000000000010151461303413300276120ustar00rootroot00000000000000from 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.py000066400000000000000000000003051461303413300270260ustar00rootroot00000000000000from 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/000077500000000000000000000000001461303413300220405ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/modeling/__init__.py000066400000000000000000000000151461303413300241450ustar00rootroot00000000000000# Helps mypy python-odmantic-1.0.2/docs/examples_src/modeling/async/000077500000000000000000000000001461303413300231555ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/modeling/async/index_creation.py000066400000000000000000000002201461303413300265140ustar00rootroot00000000000000# ... 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.py000066400000000000000000000005621461303413300254300ustar00rootroot00000000000000from 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.py000066400000000000000000000003711461303413300276700ustar00rootroot00000000000000from 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.py000066400000000000000000000004361461303413300261620ustar00rootroot00000000000000import 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.py000066400000000000000000000026751461303413300261660ustar00rootroot00000000000000from 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.py000066400000000000000000000011361461303413300251050ustar00rootroot00000000000000from 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.py000066400000000000000000000004721461303413300253270ustar00rootroot00000000000000book = 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.py000066400000000000000000000011721461303413300247220ustar00rootroot00000000000000from 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.py000066400000000000000000000012371461303413300247240ustar00rootroot00000000000000from 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.py000066400000000000000000000011011461303413300245270ustar00rootroot00000000000000from 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.py000066400000000000000000000004151461303413300247560ustar00rootroot00000000000000await 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/000077500000000000000000000000001461303413300230145ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/modeling/sync/index_creation.py000066400000000000000000000002141461303413300263560ustar00rootroot00000000000000# ... Continuation of the previous snippet ... from odmantic import SyncEngine engine = SyncEngine() engine.configure_database([Product]) python-odmantic-1.0.2/docs/examples_src/querying/000077500000000000000000000000001461303413300221055ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/querying/__init__.py000066400000000000000000000000151461303413300242120ustar00rootroot00000000000000# Helps mypy python-odmantic-1.0.2/docs/examples_src/querying/and.py000066400000000000000000000006141461303413300232220ustar00rootroot00000000000000from 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.py000066400000000000000000000005761461303413300232350ustar00rootroot00000000000000from 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.py000066400000000000000000000005211461303413300233730ustar00rootroot00000000000000from 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.py000066400000000000000000000006401461303413300242100ustar00rootroot00000000000000from 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.py000066400000000000000000000005171461303413300252620ustar00rootroot00000000000000from 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.py000066400000000000000000000006641461303413300234310ustar00rootroot00000000000000from 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.py000066400000000000000000000004721461303413300235710ustar00rootroot00000000000000from 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.py000066400000000000000000000010461461303413300233760ustar00rootroot00000000000000from 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.py000066400000000000000000000004361461303413300230700ustar00rootroot00000000000000from 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.py000066400000000000000000000010461461303413300234030ustar00rootroot00000000000000from 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.py000066400000000000000000000003561461303413300235570ustar00rootroot00000000000000from 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.py000066400000000000000000000004731461303413300253650ustar00rootroot00000000000000from 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.py000066400000000000000000000005101461303413300232510ustar00rootroot00000000000000from 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.py000066400000000000000000000004721461303413300244510ustar00rootroot00000000000000from 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.py000066400000000000000000000004461461303413300237510ustar00rootroot00000000000000from 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.py000066400000000000000000000006071461303413300231020ustar00rootroot00000000000000from 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/000077500000000000000000000000001461303413300234445ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/raw_query_usage/__init__.py000066400000000000000000000000151461303413300255510ustar00rootroot00000000000000# Helps mypy python-odmantic-1.0.2/docs/examples_src/raw_query_usage/aggregation_example.py000066400000000000000000000024151461303413300300220ustar00rootroot00000000000000from 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.py000066400000000000000000000001671461303413300271550ustar00rootroot00000000000000from 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.py000066400000000000000000000005061461303413300271560ustar00rootroot00000000000000from 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.py000066400000000000000000000003371461303413300304300ustar00rootroot00000000000000from 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.py000066400000000000000000000002431461303413300267500ustar00rootroot00000000000000from 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.py000066400000000000000000000011211461303413300273640ustar00rootroot00000000000000from 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.py000066400000000000000000000005371461303413300311120ustar00rootroot00000000000000from 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.py000066400000000000000000000012301461303413300326300ustar00rootroot00000000000000from 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.py000066400000000000000000000011621461303413300343060ustar00rootroot00000000000000from 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.py000066400000000000000000000006351461303413300277250ustar00rootroot00000000000000from 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.py000066400000000000000000000001701461303413300275620ustar00rootroot00000000000000from 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.py000066400000000000000000000002151461303413300300020ustar00rootroot00000000000000engine.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/000077500000000000000000000000001461303413300230555ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/usage_fastapi/__init__.py000066400000000000000000000000151461303413300251620ustar00rootroot00000000000000# Helps mypy python-odmantic-1.0.2/docs/examples_src/usage_fastapi/base_example.py000066400000000000000000000014701461303413300260560ustar00rootroot00000000000000from 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.py000066400000000000000000000012601461303413300264030ustar00rootroot00000000000000import 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.py000066400000000000000000000015531461303413300264300ustar00rootroot00000000000000from 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/000077500000000000000000000000001461303413300232415ustar00rootroot00000000000000python-odmantic-1.0.2/docs/examples_src/usage_pydantic/__init__.py000066400000000000000000000000151461303413300253460ustar00rootroot00000000000000# Helps mypy python-odmantic-1.0.2/docs/examples_src/usage_pydantic/custom_encoders.py000066400000000000000000000006521461303413300270120ustar00rootroot00000000000000from 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.md000066400000000000000000000305361461303413300173540ustar00rootroot00000000000000# 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/000077500000000000000000000000001461303413300163315ustar00rootroot00000000000000python-odmantic-1.0.2/docs/img/internals.excalidraw000066400000000000000000003104331461303413300224010ustar00rootroot00000000000000{ "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.png000066400000000000000000000002021461303413300233630ustar00rootroot00000000000000version https://git-lfs.github.com/spec/v1 oid sha256:8b4ce248fa26e05f17accc3bbadf995f5afb973fd0e6b50c2140fbcd49fec1d6 size 75423 python-odmantic-1.0.2/docs/index.md000077700000000000000000000000001461303413300206732../README.mdustar00rootroot00000000000000python-odmantic-1.0.2/docs/js/000077500000000000000000000000001461303413300161715ustar00rootroot00000000000000python-odmantic-1.0.2/docs/js/gitter.js000066400000000000000000000001161461303413300200230ustar00rootroot00000000000000((window.gitter = {}).chat = {}).options = { room: "odmantic/community", }; python-odmantic-1.0.2/docs/main.py000066400000000000000000000007471461303413300170630ustar00rootroot00000000000000def 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.md000066400000000000000000000106111461303413300212440ustar00rootroot00000000000000# 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.md000066400000000000000000000450421461303413300177020ustar00rootroot00000000000000# 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.md000066400000000000000000000203171461303413300177450ustar00rootroot00000000000000# 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.md000066400000000000000000000104671461303413300213110ustar00rootroot00000000000000# 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.xml000066400000000000000000000010741461303413300177430ustar00rootroot00000000000000 {%- 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.md000066400000000000000000000626541461303413300207270ustar00rootroot00000000000000# 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_}. ![Swagger for the created Tree API](img/usage_fastapi_swagger.png){: 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.md000066400000000000000000000025271461303413300211040ustar00rootroot00000000000000# 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.yml000066400000000000000000000056071461303413300166400ustar00rootroot00000000000000site_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.ini000066400000000000000000000015151461303413300163260ustar00rootroot00000000000000[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/000077500000000000000000000000001461303413300164235ustar00rootroot00000000000000python-odmantic-1.0.2/odmantic/__init__.py000066400000000000000000000007151461303413300205370ustar00rootroot00000000000000import 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.py000066400000000000000000000334011461303413300177370ustar00rootroot00000000000000from __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.py000066400000000000000000000050731461303413300202470ustar00rootroot00000000000000from __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.py000066400000000000000000001112221461303413300202410ustar00rootroot00000000000000from 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.py000066400000000000000000000066111461303413300211620ustar00rootroot00000000000000from 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.py000066400000000000000000000326611461303413300200700ustar00rootroot00000000000000from __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.py000066400000000000000000000063111461303413300201050ustar00rootroot00000000000000from 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.py000066400000000000000000001140211461303413300200740ustar00rootroot00000000000000from __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.typed000066400000000000000000000000001461303413300201100ustar00rootroot00000000000000python-odmantic-1.0.2/odmantic/query.py000066400000000000000000000111411461303413300201400ustar00rootroot00000000000000import 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.py000066400000000000000000000007451461303413300207410ustar00rootroot00000000000000from 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.py000066400000000000000000000537341461303413300204740ustar00rootroot00000000000000from __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.py000066400000000000000000000036611461303413300203150ustar00rootroot00000000000000import 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.py000066400000000000000000000022511461303413300201350ustar00rootroot00000000000000import 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.toml000066400000000000000000000077311461303413300175510ustar00rootroot00000000000000[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/000077500000000000000000000000001461303413300157675ustar00rootroot00000000000000python-odmantic-1.0.2/tests/__init__.py000066400000000000000000000000001461303413300200660ustar00rootroot00000000000000python-odmantic-1.0.2/tests/integration/000077500000000000000000000000001461303413300203125ustar00rootroot00000000000000python-odmantic-1.0.2/tests/integration/__init__.py000066400000000000000000000000001461303413300224110ustar00rootroot00000000000000python-odmantic-1.0.2/tests/integration/benchmarks/000077500000000000000000000000001461303413300224275ustar00rootroot00000000000000python-odmantic-1.0.2/tests/integration/benchmarks/__init__.py000066400000000000000000000000001461303413300245260ustar00rootroot00000000000000python-odmantic-1.0.2/tests/integration/benchmarks/models.py000066400000000000000000000040551461303413300242700ustar00rootroot00000000000000"""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.py000066400000000000000000000041271461303413300263200ustar00rootroot00000000000000import 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.py000066400000000000000000000037041461303413300261570ustar00rootroot00000000000000import 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.py000066400000000000000000000057701461303413300225220ustar00rootroot00000000000000import 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/000077500000000000000000000000001461303413300217415ustar00rootroot00000000000000python-odmantic-1.0.2/tests/integration/fastapi/__init__.py000066400000000000000000000000001461303413300240400ustar00rootroot00000000000000python-odmantic-1.0.2/tests/integration/fastapi/conftest.py000066400000000000000000000003411461303413300241360ustar00rootroot00000000000000import 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.py000066400000000000000000000065651461303413300256460ustar00rootroot00000000000000from 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.py000066400000000000000000000120141461303413300246330ustar00rootroot00000000000000from 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.py000066400000000000000000000230231461303413300246340ustar00rootroot00000000000000from 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.py000066400000000000000000001171241461303413300231760ustar00rootroot00000000000000from 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.py000066400000000000000000000273331461303413300252160ustar00rootroot00000000000000import 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.py000066400000000000000000000227761461303413300230500ustar00rootroot00000000000000import 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.py000066400000000000000000000246331461303413300231000ustar00rootroot00000000000000import 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.py000066400000000000000000000340571461303413300234170ustar00rootroot00000000000000import 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.py000066400000000000000000000134331461303413300230730ustar00rootroot00000000000000import 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.py000066400000000000000000000031121461303413300225270ustar00rootroot00000000000000import 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.py000066400000000000000000000003041461303413300220210ustar00rootroot00000000000000from 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.py000066400000000000000000000011351461303413300221320ustar00rootroot00000000000000from 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.py000066400000000000000000000010601461303413300210700ustar00rootroot00000000000000from 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/000077500000000000000000000000001461303413300167465ustar00rootroot00000000000000python-odmantic-1.0.2/tests/unit/__init__.py000066400000000000000000000000001461303413300210450ustar00rootroot00000000000000python-odmantic-1.0.2/tests/unit/test_bson_fields.py000066400000000000000000000156241461303413300226560ustar00rootroot00000000000000import 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.py000066400000000000000000000020771461303413300216320ustar00rootroot00000000000000import 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.py000066400000000000000000000013151461303413300230370ustar00rootroot00000000000000import 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.py000066400000000000000000000010271461303413300251320ustar00rootroot00000000000000from 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.py000066400000000000000000000057321461303413300214510ustar00rootroot00000000000000from 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.py000066400000000000000000000074701461303413300237060ustar00rootroot00000000000000from 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.py000066400000000000000000000106571461303413300242760ustar00rootroot00000000000000import 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.py000066400000000000000000000305641461303413300236770ustar00rootroot00000000000000import 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.py000066400000000000000000000464461461303413300226520ustar00rootroot00000000000000from 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.py000066400000000000000000000060171461303413300247360ustar00rootroot00000000000000import 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.py000066400000000000000000000021541461303413300215260ustar00rootroot00000000000000from 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.py000066400000000000000000000016441461303413300223220ustar00rootroot00000000000000import 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.py000066400000000000000000000135251461303413300220500ustar00rootroot00000000000000import 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.py000066400000000000000000000014641461303413300216760ustar00rootroot00000000000000from 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/000077500000000000000000000000001461303413300165765ustar00rootroot00000000000000python-odmantic-1.0.2/tests/zoo/__init__.py000066400000000000000000000000001461303413300206750ustar00rootroot00000000000000python-odmantic-1.0.2/tests/zoo/book_embedded.py000066400000000000000000000003171461303413300217140ustar00rootroot00000000000000from 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.py000066400000000000000000000003571461303413300221250ustar00rootroot00000000000000from 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.py000066400000000000000000000004461461303413300220000ustar00rootroot00000000000000from 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.py000066400000000000000000000005641461303413300211400ustar00rootroot00000000000000from 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.py000066400000000000000000000003451461303413300222660ustar00rootroot00000000000000from 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.py000066400000000000000000000002211461303413300204510ustar00rootroot00000000000000from 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.py000066400000000000000000000002721461303413300204450ustar00rootroot00000000000000from 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.py000066400000000000000000000006551461303413300201150ustar00rootroot00000000000000import 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.py000066400000000000000000000003171461303413300217110ustar00rootroot00000000000000from 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.ini000066400000000000000000000020411461303413300161350ustar00rootroot00000000000000[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