├── .github ├── DISCUSSION_TEMPLATE │ └── questions.yml ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── config.yml │ └── privileged.yml ├── dependabot.yml ├── labeler.yml └── workflows │ ├── issue-manager.yml │ ├── labeler.yml │ ├── latest-changes.yml │ ├── publish.yml │ ├── smokeshow.yml │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── SECURITY.md ├── pydantic_sqlalchemy ├── __init__.py └── main.py ├── pyproject.toml ├── requirements-github-actions.txt ├── requirements-tests.txt ├── requirements.txt ├── scripts ├── format.sh ├── lint.sh └── test.sh └── tests ├── __init__.py └── test_pydantic_sqlalchemy.py /.github/DISCUSSION_TEMPLATE/questions.yml: -------------------------------------------------------------------------------- 1 | labels: [question] 2 | body: 3 | - type: markdown 4 | attributes: 5 | value: | 6 | Thanks for your interest in this project! 🚀 7 | 8 | Please follow these instructions, fill every question, and do every step. 🙏 9 | 10 | I'm asking this because answering questions and solving problems in GitHub is what consumes most of the time. 11 | 12 | I end up not being able to add new features, fix bugs, review pull requests, etc. as fast as I wish because I have to spend too much time handling questions. 13 | 14 | All that, on top of all the incredible help provided by a bunch of community members, that give a lot of their time to come here and help others. 15 | 16 | That's a lot of work, but if more users came to help others like them just a little bit more, it would be much less effort for them (and you and me 😅). 17 | 18 | By asking questions in a structured way (following this) it will be much easier to help you. 19 | 20 | And there's a high chance that you will find the solution along the way and you won't even have to submit it and wait for an answer. 😎 21 | 22 | As there are too many questions, I'll have to discard and close the incomplete ones. That will allow me (and others) to focus on helping people like you that follow the whole process and help us help you. 🤓 23 | - type: checkboxes 24 | id: checks 25 | attributes: 26 | label: First Check 27 | description: Please confirm and check all the following options. 28 | options: 29 | - label: I added a very descriptive title here. 30 | required: true 31 | - label: I used the GitHub search to find a similar question and didn't find it. 32 | required: true 33 | - label: I searched in the documentation/README. 34 | required: true 35 | - label: I already searched in Google "How to do X" and didn't find any information. 36 | required: true 37 | - label: I already read and followed all the tutorial in the docs/README and didn't find an answer. 38 | required: true 39 | - type: checkboxes 40 | id: help 41 | attributes: 42 | label: Commit to Help 43 | description: | 44 | After submitting this, I commit to one of: 45 | 46 | * Read open questions until I find 2 where I can help someone and add a comment to help there. 47 | * I already hit the "watch" button in this repository to receive notifications and I commit to help at least 2 people that ask questions in the future. 48 | 49 | options: 50 | - label: I commit to help with one of those options 👆 51 | required: true 52 | - type: textarea 53 | id: example 54 | attributes: 55 | label: Example Code 56 | description: | 57 | Please add a self-contained, [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) with your use case. 58 | 59 | If I (or someone) can copy it, run it, and see it right away, there's a much higher chance I (or someone) will be able to help you. 60 | 61 | placeholder: | 62 | Write your example code here. 63 | render: Text 64 | validations: 65 | required: true 66 | - type: textarea 67 | id: description 68 | attributes: 69 | label: Description 70 | description: | 71 | What is the problem, question, or error? 72 | 73 | Write a short description telling me what you are doing, what you expect to happen, and what is currently happening. 74 | placeholder: | 75 | * Create a SQLAlchemy 2.0 model `User`. 76 | * Create a Pydantic model with `sqlalchemy_to_pydantic(User)`. 77 | * An error is thrown unless I use SQLAlchemy 1.4, but I expect it to work. 78 | validations: 79 | required: true 80 | - type: dropdown 81 | id: os 82 | attributes: 83 | label: Operating System 84 | description: What operating system are you on? 85 | multiple: true 86 | options: 87 | - Linux 88 | - Windows 89 | - macOS 90 | - Other 91 | validations: 92 | required: true 93 | - type: textarea 94 | id: os-details 95 | attributes: 96 | label: Operating System Details 97 | description: You can add more details about your operating system here, in particular if you chose "Other". 98 | validations: 99 | required: true 100 | - type: input 101 | id: python-version 102 | attributes: 103 | label: Python Version 104 | description: | 105 | What Python version are you using? 106 | 107 | You can find the Python version with: 108 | 109 | ```bash 110 | python --version 111 | ``` 112 | validations: 113 | required: true 114 | - type: textarea 115 | id: context 116 | attributes: 117 | label: Additional Context 118 | description: Add any additional context information or screenshots you think are useful. 119 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [tiangolo] 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: Security Contact 4 | about: Please report security vulnerabilities to security@tiangolo.com 5 | - name: Question or Problem 6 | about: Ask a question or ask about a problem in GitHub Discussions. 7 | url: https://github.com/tiangolo/pydantic-sqlalchemy/discussions/categories/questions 8 | - name: Feature Request 9 | about: To suggest an idea or ask about a feature, please start with a question saying what you would like to achieve. There might be a way to do it already. 10 | url: https://github.com/tiangolo/pydantic-sqlalchemy/discussions/categories/questions 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/privileged.yml: -------------------------------------------------------------------------------- 1 | name: Privileged 2 | description: You are @tiangolo or he asked you directly to create an issue here. If not, check the other options. 👇 3 | body: 4 | - type: markdown 5 | attributes: 6 | value: | 7 | Thanks for your interest in this project! 🚀 8 | 9 | If you are not @tiangolo or he didn't ask you directly to create an issue here, please start the conversation in a [Question in GitHub Discussions](https://github.com/tiangolo/pydantic-sqlalchemy/discussions/categories/questions) instead. 10 | - type: checkboxes 11 | id: privileged 12 | attributes: 13 | label: Privileged issue 14 | description: Confirm that you are allowed to create an issue here. 15 | options: 16 | - label: I'm @tiangolo or he asked me directly to create an issue here. 17 | required: true 18 | - type: textarea 19 | id: content 20 | attributes: 21 | label: Issue Content 22 | description: Add the content of the issue here. 23 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | # GitHub Actions 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | commit-message: 9 | prefix: ⬆ 10 | # Python 11 | - package-ecosystem: "pip" 12 | directory: "/" 13 | schedule: 14 | interval: "daily" 15 | commit-message: 16 | prefix: ⬆ 17 | -------------------------------------------------------------------------------- /.github/labeler.yml: -------------------------------------------------------------------------------- 1 | internal: 2 | - all: 3 | - changed-files: 4 | - any-glob-to-any-file: 5 | - .github/** 6 | - scripts/** 7 | - .gitignore 8 | - .pre-commit-config.yaml 9 | - requirements*.txt 10 | - all-globs-to-all-files: 11 | - '!pyproject.toml' 12 | -------------------------------------------------------------------------------- /.github/workflows/issue-manager.yml: -------------------------------------------------------------------------------- 1 | name: "Issue Manager" 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 * * *" 6 | 7 | permissions: 8 | issues: write 9 | pull-requests: write 10 | 11 | jobs: 12 | issue-manager: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: tiangolo/issue-manager@master 16 | with: 17 | token: ${{ secrets.GITHUB_TOKEN }} 18 | config: > 19 | { 20 | "answered": { 21 | "users": ["tiangolo"], 22 | "delay": 864000, 23 | "message": "Assuming the original issue was solved, it will be automatically closed now. But feel free to add more comments or create new issues." 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/labeler.yml: -------------------------------------------------------------------------------- 1 | name: Labels 2 | on: 3 | pull_request_target: 4 | types: 5 | - opened 6 | - synchronize 7 | - reopened 8 | # For label-checker 9 | - labeled 10 | - unlabeled 11 | 12 | jobs: 13 | labeler: 14 | permissions: 15 | contents: read 16 | pull-requests: write 17 | runs-on: ubuntu-latest 18 | steps: 19 | - uses: actions/labeler@v5 20 | if: ${{ github.event.action != 'labeled' && github.event.action != 'unlabeled' }} 21 | - run: echo "Done adding labels" 22 | # Run this after labeler applied labels 23 | check-labels: 24 | needs: 25 | - labeler 26 | permissions: 27 | pull-requests: read 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: docker://agilepathway/pull-request-label-checker:latest 31 | with: 32 | one_of: breaking,security,feature,bug,refactor,upgrade,docs,lang-all,internal 33 | repo_token: ${{ secrets.GITHUB_TOKEN }} 34 | -------------------------------------------------------------------------------- /.github/workflows/latest-changes.yml: -------------------------------------------------------------------------------- 1 | name: Latest Changes 2 | 3 | on: 4 | pull_request_target: 5 | branches: 6 | - master 7 | types: 8 | - closed 9 | workflow_dispatch: 10 | inputs: 11 | number: 12 | description: PR number 13 | required: true 14 | debug_enabled: 15 | description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' 16 | required: false 17 | default: 'false' 18 | 19 | jobs: 20 | latest-changes: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - uses: actions/checkout@v4 24 | with: 25 | token: ${{ secrets.PYDANTIC_SQLALCHEMY_LATEST_CHANGES }} 26 | - uses: tiangolo/latest-changes@0.3.2 27 | with: 28 | token: ${{ secrets.GITHUB_TOKEN }} 29 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish 2 | 3 | on: 4 | release: 5 | types: 6 | - created 7 | workflow_dispatch: 8 | inputs: 9 | debug_enabled: 10 | description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' 11 | required: false 12 | default: 'false' 13 | 14 | jobs: 15 | publish: 16 | runs-on: ubuntu-latest 17 | permissions: 18 | id-token: write 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: Set up Python 22 | uses: actions/setup-python@v5 23 | with: 24 | python-version: "3.11" 25 | - name: Install build dependencies 26 | run: pip install build 27 | - name: Build distribution 28 | run: python -m build 29 | - name: Publish 30 | uses: pypa/gh-action-pypi-publish@v1.12.4 31 | -------------------------------------------------------------------------------- /.github/workflows/smokeshow.yml: -------------------------------------------------------------------------------- 1 | name: Smokeshow 2 | 3 | on: 4 | workflow_run: 5 | workflows: [Test] 6 | types: [completed] 7 | 8 | permissions: 9 | statuses: write 10 | 11 | env: 12 | UV_SYSTEM_PYTHON: 1 13 | 14 | jobs: 15 | smokeshow: 16 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 17 | runs-on: ubuntu-latest 18 | 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: actions/setup-python@v5 22 | with: 23 | python-version: '3.9' 24 | - name: Setup uv 25 | uses: astral-sh/setup-uv@v5 26 | with: 27 | version: "0.4.15" 28 | enable-cache: true 29 | cache-dependency-glob: | 30 | requirements**.txt 31 | pyproject.toml 32 | - run: uv pip install -r requirements-github-actions.txt 33 | - uses: actions/download-artifact@v4 34 | with: 35 | name: coverage-html 36 | path: htmlcov 37 | github-token: ${{ secrets.GITHUB_TOKEN }} 38 | run-id: ${{ github.event.workflow_run.id }} 39 | - run: smokeshow upload htmlcov 40 | env: 41 | SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Coverage {coverage-percentage} 42 | SMOKESHOW_GITHUB_COVERAGE_THRESHOLD: 95 43 | SMOKESHOW_GITHUB_CONTEXT: coverage 44 | SMOKESHOW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | SMOKESHOW_GITHUB_PR_HEAD_SHA: ${{ github.event.workflow_run.head_sha }} 46 | SMOKESHOW_AUTH_KEY: ${{ secrets.SMOKESHOW_AUTH_KEY }} 47 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | types: 9 | - opened 10 | - synchronize 11 | workflow_dispatch: 12 | inputs: 13 | debug_enabled: 14 | description: 'Run the build with tmate debugging enabled (https://github.com/marketplace/actions/debugging-with-tmate)' 15 | required: false 16 | default: 'false' 17 | # schedule: 18 | # # cron every week on monday 19 | # - cron: "0 1 * * 1" 20 | 21 | env: 22 | UV_SYSTEM_PYTHON: 1 23 | 24 | jobs: 25 | test: 26 | runs-on: ubuntu-latest 27 | strategy: 28 | matrix: 29 | python-version: 30 | - "3.8" 31 | - "3.9" 32 | - "3.10" 33 | - "3.11" 34 | - "3.12" 35 | - "3.13" 36 | fail-fast: false 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | - name: Set up Python 41 | uses: actions/setup-python@v5 42 | with: 43 | python-version: ${{ matrix.python-version }} 44 | - name: Setup uv 45 | uses: astral-sh/setup-uv@v5 46 | with: 47 | version: "0.4.15" 48 | enable-cache: true 49 | cache-dependency-glob: | 50 | requirements**.txt 51 | pyproject.toml 52 | # Allow debugging with tmate 53 | - name: Setup tmate session 54 | uses: mxschmitt/action-tmate@v3 55 | if: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.debug_enabled == 'true' }} 56 | with: 57 | limit-access-to-actor: true 58 | - name: Install Dependencies 59 | run: uv pip install -r requirements-tests.txt 60 | # - name: Lint 61 | # run: bash scripts/lint.sh 62 | - run: mkdir coverage 63 | - name: Test 64 | run: bash scripts/test.sh 65 | env: 66 | COVERAGE_FILE: coverage/.coverage.${{ runner.os }}-py${{ matrix.python-version }} 67 | CONTEXT: ${{ runner.os }}-py${{ matrix.python-version }} 68 | - name: Store coverage files 69 | uses: actions/upload-artifact@v4 70 | with: 71 | name: coverage-${{ matrix.python-version }} 72 | path: coverage 73 | include-hidden-files: true 74 | 75 | coverage-combine: 76 | needs: 77 | - test 78 | runs-on: ubuntu-latest 79 | steps: 80 | - uses: actions/checkout@v4 81 | - uses: actions/setup-python@v5 82 | with: 83 | python-version: '3.12' 84 | - name: Setup uv 85 | uses: astral-sh/setup-uv@v5 86 | with: 87 | version: "0.4.15" 88 | enable-cache: true 89 | cache-dependency-glob: | 90 | requirements**.txt 91 | pyproject.toml 92 | - name: Get coverage files 93 | uses: actions/download-artifact@v4 94 | with: 95 | pattern: coverage-* 96 | path: coverage 97 | merge-multiple: true 98 | - run: uv pip install -r requirements-tests.txt 99 | - run: ls -la coverage 100 | - run: coverage combine coverage 101 | - run: coverage report 102 | - run: coverage html --title "Coverage for ${{ github.sha }}" 103 | - name: Store coverage HTML 104 | uses: actions/upload-artifact@v4 105 | with: 106 | name: coverage-html 107 | path: htmlcov 108 | include-hidden-files: true 109 | 110 | # https://github.com/marketplace/actions/alls-green#why 111 | alls-green: # This job does nothing and is only used for the branch protection 112 | if: always() 113 | needs: 114 | - coverage-combine 115 | runs-on: ubuntu-latest 116 | steps: 117 | - name: Decide whether the needed jobs succeeded or failed 118 | uses: re-actors/alls-green@release/v1 119 | with: 120 | jobs: ${{ toJSON(needs) }} 121 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .vscode 3 | *.egg-info 4 | .mypy_cache 5 | dist 6 | .coverage 7 | coverage.xml 8 | htmlcov 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2020 Sebastián Ramírez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Pydantic-SQLAlchemy 2 | 3 | 4 | Test 5 | 6 | 7 | Publish 8 | 9 | 10 | Coverage 11 | 12 | 13 | Package version 14 | 15 | 16 | Tools to generate Pydantic models from SQLAlchemy models. 17 | 18 | Still experimental. 19 | 20 | ## 🚨 WARNING: Use SQLModel instead 🚨 21 | 22 | [SQLModel](https://sqlmodel.tiangolo.com/) is a library that solves the same problem as this one, but in a much better way, also solving several other problems at the same time. 23 | 24 | This project was to solve some simple use cases, to generate dynamic Pydantic models from SQLAlchemy models. But the result cannot be used very well in code as it doesn't have all the autocompletion and inline errors that a Pydantic model would have. 25 | 26 | This was a very simple implementation, SQLModel is a much better solution, much better design and work behind it. 27 | 28 | For most of the cases where you would use `pydantic-sqlalchemy`, you should use [SQLModel](https://sqlmodel.tiangolo.com/) instead. 29 | 30 | ## How to use 31 | 32 | Quick example: 33 | 34 | ```Python 35 | from typing import List 36 | 37 | from pydantic_sqlalchemy import sqlalchemy_to_pydantic 38 | from sqlalchemy import Column, ForeignKey, Integer, String, create_engine 39 | from sqlalchemy.ext.declarative import declarative_base 40 | from sqlalchemy.orm import Session, relationship, sessionmaker 41 | 42 | Base = declarative_base() 43 | 44 | engine = create_engine("sqlite://", echo=True) 45 | 46 | 47 | class User(Base): 48 | __tablename__ = "users" 49 | 50 | id = Column(Integer, primary_key=True) 51 | name = Column(String) 52 | fullname = Column(String) 53 | nickname = Column(String) 54 | 55 | addresses = relationship( 56 | "Address", back_populates="user", cascade="all, delete, delete-orphan" 57 | ) 58 | 59 | 60 | class Address(Base): 61 | __tablename__ = "addresses" 62 | id = Column(Integer, primary_key=True) 63 | email_address = Column(String, nullable=False) 64 | user_id = Column(Integer, ForeignKey("users.id")) 65 | 66 | user = relationship("User", back_populates="addresses") 67 | 68 | 69 | PydanticUser = sqlalchemy_to_pydantic(User) 70 | PydanticAddress = sqlalchemy_to_pydantic(Address) 71 | 72 | 73 | class PydanticUserWithAddresses(PydanticUser): 74 | addresses: List[PydanticAddress] = [] 75 | 76 | 77 | Base.metadata.create_all(engine) 78 | 79 | 80 | LocalSession = sessionmaker(bind=engine) 81 | 82 | db: Session = LocalSession() 83 | 84 | ed_user = User(name="ed", fullname="Ed Jones", nickname="edsnickname") 85 | 86 | address = Address(email_address="ed@example.com") 87 | address2 = Address(email_address="eddy@example.com") 88 | ed_user.addresses = [address, address2] 89 | db.add(ed_user) 90 | db.commit() 91 | 92 | 93 | def test_pydantic_sqlalchemy(): 94 | user = db.query(User).first() 95 | pydantic_user = PydanticUser.from_orm(user) 96 | data = pydantic_user.dict() 97 | assert data == { 98 | "fullname": "Ed Jones", 99 | "id": 1, 100 | "name": "ed", 101 | "nickname": "edsnickname", 102 | } 103 | pydantic_user_with_addresses = PydanticUserWithAddresses.from_orm(user) 104 | data = pydantic_user_with_addresses.dict() 105 | assert data == { 106 | "fullname": "Ed Jones", 107 | "id": 1, 108 | "name": "ed", 109 | "nickname": "edsnickname", 110 | "addresses": [ 111 | {"email_address": "ed@example.com", "id": 1, "user_id": 1}, 112 | {"email_address": "eddy@example.com", "id": 2, "user_id": 1}, 113 | ], 114 | } 115 | ``` 116 | 117 | ## Release Notes 118 | 119 | ### Latest Changes 120 | 121 | #### Docs 122 | 123 | * 📝 Add SQLModel docs. PR [#70](https://github.com/tiangolo/pydantic-sqlalchemy/pull/70) by [@tiangolo](https://github.com/tiangolo). 124 | 125 | #### Internal 126 | 127 | * Enable CI for Python 3.9 - 3.13. [af66239](https://github.com/tiangolo/pydantic-sqlalchemy/commit/af66239b3c0a949b5f1fe6a99b4f96a78e9e659c) by [@tiangolo](https://github.com/tiangolo). 128 | * ⬆ Bump ruff from 0.7.1 to 0.9.8. PR [#156](https://github.com/tiangolo/pydantic-sqlalchemy/pull/156) by [@dependabot[bot]](https://github.com/apps/dependabot). 129 | * ⬆ Bump astral-sh/setup-uv from 3 to 5. PR [#144](https://github.com/tiangolo/pydantic-sqlalchemy/pull/144) by [@dependabot[bot]](https://github.com/apps/dependabot). 130 | * ⬆ Bump pypa/gh-action-pypi-publish from 1.11.0 to 1.12.4. PR [#151](https://github.com/tiangolo/pydantic-sqlalchemy/pull/151) by [@dependabot[bot]](https://github.com/apps/dependabot). 131 | * 🔥 Drop support for Python 3.7. PR [#154](https://github.com/tiangolo/pydantic-sqlalchemy/pull/154) by [@tiangolo](https://github.com/tiangolo). 132 | * ⬆ Bump tiangolo/latest-changes from 0.3.1 to 0.3.2. PR [#133](https://github.com/tiangolo/pydantic-sqlalchemy/pull/133) by [@dependabot[bot]](https://github.com/apps/dependabot). 133 | * ⬆ Bump pypa/gh-action-pypi-publish from 1.10.3 to 1.11.0. PR [#127](https://github.com/tiangolo/pydantic-sqlalchemy/pull/127) by [@dependabot[bot]](https://github.com/apps/dependabot). 134 | * ⬆ Bump ruff from 0.6.9 to 0.7.1. PR [#126](https://github.com/tiangolo/pydantic-sqlalchemy/pull/126) by [@dependabot[bot]](https://github.com/apps/dependabot). 135 | * ⬆ Update pytest requirement from <8.0.0,>=7.0.1 to >=7.0.1,<9.0.0. PR [#104](https://github.com/tiangolo/pydantic-sqlalchemy/pull/104) by [@dependabot[bot]](https://github.com/apps/dependabot). 136 | * 👷 Add labeler GitHub Action. PR [#102](https://github.com/tiangolo/pydantic-sqlalchemy/pull/102) by [@tiangolo](https://github.com/tiangolo). 137 | * 🔧 Re-create Python project config, dependencies, and CI, just to make CI run. PR [#101](https://github.com/tiangolo/pydantic-sqlalchemy/pull/101) by [@tiangolo](https://github.com/tiangolo). 138 | * ⬆ Bump actions/checkout from 2 to 4. PR [#62](https://github.com/tiangolo/pydantic-sqlalchemy/pull/62) by [@dependabot[bot]](https://github.com/apps/dependabot). 139 | * 👷 Update issue-manager.yml GitHub Action permissions. PR [#78](https://github.com/tiangolo/pydantic-sqlalchemy/pull/78) by [@tiangolo](https://github.com/tiangolo). 140 | * 👷 Update `latest-changes` GitHub Action. PR [#79](https://github.com/tiangolo/pydantic-sqlalchemy/pull/79) by [@tiangolo](https://github.com/tiangolo). 141 | * 🔧 Add GitHub templates for discussions and issues, and security policy. PR [#76](https://github.com/tiangolo/pydantic-sqlalchemy/pull/76) by [@alejsdev](https://github.com/alejsdev). 142 | * 👷 Add dependabot. PR [#60](https://github.com/tiangolo/pydantic-sqlalchemy/pull/60) by [@tiangolo](https://github.com/tiangolo). 143 | * 👷 Update latest-changes GitHub Action. PR [#59](https://github.com/tiangolo/pydantic-sqlalchemy/pull/59) by [@tiangolo](https://github.com/tiangolo). 144 | 145 | ### 0.0.9 146 | 147 | * ✨ Add `poetry-version-plugin`, remove `importlib-metadata` dependency. PR [#32](https://github.com/tiangolo/pydantic-sqlalchemy/pull/32) by [@tiangolo](https://github.com/tiangolo). 148 | 149 | ### 0.0.8.post1 150 | 151 | * 💚 Fix setting up Poetry for GitHub Action Publish. PR [#23](https://github.com/tiangolo/pydantic-sqlalchemy/pull/23) by [@tiangolo](https://github.com/tiangolo). 152 | 153 | ### 0.0.8 154 | 155 | * ⬆️ Upgrade `importlib-metadata` to 3.0.0. PR [#22](https://github.com/tiangolo/pydantic-sqlalchemy/pull/22) by [@tiangolo](https://github.com/tiangolo). 156 | * 👷 Add GitHub Action latest-changes. PR [#20](https://github.com/tiangolo/pydantic-sqlalchemy/pull/20) by [@tiangolo](https://github.com/tiangolo). 157 | * 💚 Fix GitHub Actions Poetry setup. PR [#21](https://github.com/tiangolo/pydantic-sqlalchemy/pull/21) by [@tiangolo](https://github.com/tiangolo). 158 | 159 | ### 0.0.7 160 | 161 | * Update requirements of `importlib-metadata` to support the latest version `2.0.0`. PR [#11](https://github.com/tiangolo/pydantic-sqlalchemy/pull/11). 162 | 163 | ### 0.0.6 164 | 165 | * Add support for SQLAlchemy extended types like [sqlalchemy-utc: UtcDateTime](https://github.com/spoqa/sqlalchemy-utc). PR [#9](https://github.com/tiangolo/pydantic-sqlalchemy/pull/9). 166 | 167 | ### 0.0.5 168 | 169 | * Exclude columns before checking their Python types. PR [#5](https://github.com/tiangolo/pydantic-sqlalchemy/pull/5) by [@ZachMyers3](https://github.com/ZachMyers3). 170 | 171 | ### 0.0.4 172 | 173 | * Do not include SQLAlchemy defaults in Pydantic models. PR [#4](https://github.com/tiangolo/pydantic-sqlalchemy/pull/4). 174 | 175 | ### 0.0.3 176 | 177 | * Add support for `exclude` to exclude columns from Pydantic model. PR [#3](https://github.com/tiangolo/pydantic-sqlalchemy/pull/3). 178 | * Add support for overriding the Pydantic `config`. PR [#1](https://github.com/tiangolo/pydantic-sqlalchemy/pull/1) by [@pyropy](https://github.com/pyropy). 179 | * Add CI with GitHub Actions. PR [#2](https://github.com/tiangolo/pydantic-sqlalchemy/pull/2). 180 | 181 | ## License 182 | 183 | This project is licensed under the terms of the MIT license. 184 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Security is very important for this project and its community. 🔒 4 | 5 | Learn more about it below. 👇 6 | 7 | ## Versions 8 | 9 | The latest version or release is supported. 10 | 11 | You are encouraged to write tests for your application and update your versions frequently after ensuring that your tests are passing. This way you will benefit from the latest features, bug fixes, and **security fixes**. 12 | 13 | ## Reporting a Vulnerability 14 | 15 | 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: security@tiangolo.com. Please try to be as explicit as possible, describing all the steps and example code to reproduce the security issue. 16 | 17 | I (the author, [@tiangolo](https://twitter.com/tiangolo)) will review it thoroughly and get back to you. 18 | 19 | ## Public Discussions 20 | 21 | Please restrain from publicly discussing a potential security vulnerability. 🙊 22 | 23 | It's better to discuss privately and try to find a solution first, to limit the potential impact as much as possible. 24 | 25 | --- 26 | 27 | Thanks for your help! 28 | 29 | The community and I thank you for that. 🙇 30 | -------------------------------------------------------------------------------- /pydantic_sqlalchemy/__init__.py: -------------------------------------------------------------------------------- 1 | from .main import sqlalchemy_to_pydantic 2 | 3 | __version__ = "0.0.9" 4 | -------------------------------------------------------------------------------- /pydantic_sqlalchemy/main.py: -------------------------------------------------------------------------------- 1 | from typing import Container, Optional, Type 2 | 3 | from pydantic import BaseConfig, BaseModel, create_model 4 | from sqlalchemy.inspection import inspect 5 | from sqlalchemy.orm.properties import ColumnProperty 6 | 7 | 8 | class OrmConfig(BaseConfig): 9 | orm_mode = True 10 | 11 | 12 | def sqlalchemy_to_pydantic( 13 | db_model: Type, *, config: Type = OrmConfig, exclude: Container[str] = [] 14 | ) -> Type[BaseModel]: 15 | mapper = inspect(db_model) 16 | fields = {} 17 | for attr in mapper.attrs: 18 | if isinstance(attr, ColumnProperty): 19 | if attr.columns: 20 | name = attr.key 21 | if name in exclude: 22 | continue 23 | column = attr.columns[0] 24 | python_type: Optional[type] = None 25 | if hasattr(column.type, "impl"): 26 | if hasattr(column.type.impl, "python_type"): 27 | python_type = column.type.impl.python_type 28 | elif hasattr(column.type, "python_type"): 29 | python_type = column.type.python_type 30 | assert python_type, f"Could not infer python_type for {column}" 31 | default = None 32 | if column.default is None and not column.nullable: 33 | default = ... 34 | fields[name] = (python_type, default) 35 | pydantic_model = create_model( 36 | db_model.__name__, __config__=config, **fields # type: ignore 37 | ) 38 | return pydantic_model 39 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["pdm-backend"] 3 | build-backend = "pdm.backend" 4 | 5 | [project] 6 | name = "pydantic-sqlalchemy" 7 | dynamic = ["version"] 8 | description = "Tools to convert SQLAlchemy models to Pydantic models" 9 | readme = "README.md" 10 | requires-python = ">=3.8" 11 | authors = [ 12 | { name = "Sebastián Ramírez", email = "tiangolo@gmail.com" }, 13 | ] 14 | 15 | dependencies = [ 16 | "SQLAlchemy >=1.3.16,<2.0.0", 17 | "pydantic >=1.5.1,<2.0.0", 18 | ] 19 | 20 | [project.urls] 21 | Homepage = "https://github.com/tiangolo/pydantic-sqlalchemy" 22 | Repository = "https://github.com/tiangolo/pydantic-sqlalchemy" 23 | Issues = "https://github.com/tiangolo/pydantic-sqlalchemy/issues" 24 | 25 | [tool.pdm] 26 | version = { source = "file", path = "pydantic_sqlalchemy/__init__.py" } 27 | distribution = true 28 | 29 | [tool.pdm.build] 30 | source-includes = [ 31 | "tests/", 32 | "requirements*.txt", 33 | "scripts/", 34 | ] 35 | 36 | [tool.coverage.run] 37 | parallel = true 38 | data_file = "coverage/.coverage" 39 | source = [ 40 | "tests", 41 | "pydantic_sqlalchemy" 42 | ] 43 | context = '${CONTEXT}' 44 | dynamic_context = "test_function" 45 | 46 | [tool.coverage.report] 47 | show_missing = true 48 | sort = "-Cover" 49 | exclude_lines = [ 50 | "pragma: no cover", 51 | "@overload", 52 | 'if __name__ == "__main__":', 53 | "if TYPE_CHECKING:", 54 | ] 55 | 56 | [tool.coverage.html] 57 | show_contexts = true 58 | 59 | [tool.mypy] 60 | strict = true 61 | 62 | [tool.ruff.lint] 63 | select = [ 64 | "E", # pycodestyle errors 65 | "W", # pycodestyle warnings 66 | "F", # pyflakes 67 | "I", # isort 68 | "B", # flake8-bugbear 69 | "C4", # flake8-comprehensions 70 | "UP", # pyupgrade 71 | ] 72 | ignore = [ 73 | "E501", # line too long, handled by black 74 | "B008", # do not perform function calls in argument defaults 75 | "C901", # too complex 76 | "W191", # indentation contains tabs 77 | ] 78 | 79 | [tool.ruff.lint.per-file-ignores] 80 | # "__init__.py" = ["F401"] 81 | 82 | [tool.ruff.lint.isort] 83 | known-third-party = ["sqlalchemy", "pydantic"] 84 | 85 | [tool.ruff.lint.pyupgrade] 86 | # Preserve types, even if a file imports `from __future__ import annotations`. 87 | keep-runtime-typing = true 88 | -------------------------------------------------------------------------------- /requirements-github-actions.txt: -------------------------------------------------------------------------------- 1 | smokeshow 2 | -------------------------------------------------------------------------------- /requirements-tests.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | pytest >=7.0.1,<9.0.0 3 | coverage[toml] >=6.2,<8.0 4 | mypy ==1.4.1 5 | ruff ==0.9.8 6 | typing-extensions ==4.6.1 7 | 8 | pytest-cov ==4.1.0 9 | sqlalchemy-utc >=0.14.0,<0.15.0 10 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | -e . 2 | 3 | -r requirements-tests.txt 4 | 5 | pre-commit >=2.17.0,<5.0.0 6 | -------------------------------------------------------------------------------- /scripts/format.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | set -x 3 | 4 | ruff check pydantic_sqlalchemy tests scripts --fix 5 | ruff format pydantic_sqlalchemy tests scripts 6 | -------------------------------------------------------------------------------- /scripts/lint.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | mypy pydantic_sqlalchemy 7 | ruff check pydantic_sqlalchemy tests scripts 8 | ruff format sqlmpydantic_sqlalchemyodel tests scripts --check 9 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | set -x 5 | 6 | coverage run -m pytest tests 7 | coverage combine 8 | coverage report 9 | coverage html 10 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tiangolo/pydantic-sqlalchemy/b25519f74bb3ff25fb35b94c671aea9f1e3d9f04/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_pydantic_sqlalchemy.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timezone 2 | from typing import List 3 | 4 | from pydantic_sqlalchemy import sqlalchemy_to_pydantic 5 | from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, create_engine 6 | from sqlalchemy.ext.declarative import declarative_base 7 | from sqlalchemy.orm import Session, relationship, sessionmaker 8 | from sqlalchemy_utc import UtcDateTime 9 | 10 | Base = declarative_base() 11 | 12 | engine = create_engine("sqlite://", echo=True) 13 | 14 | 15 | def utc_now() -> datetime: 16 | return datetime.now(tz=timezone.utc) 17 | 18 | 19 | class User(Base): 20 | __tablename__ = "users" 21 | 22 | id = Column(Integer, primary_key=True) 23 | name = Column(String) 24 | fullname = Column(String) 25 | nickname = Column(String) 26 | created = Column(DateTime, default=datetime.utcnow) 27 | updated = Column(UtcDateTime, default=utc_now, onupdate=utc_now) 28 | 29 | addresses = relationship( 30 | "Address", back_populates="user", cascade="all, delete, delete-orphan" 31 | ) 32 | 33 | 34 | class Address(Base): 35 | __tablename__ = "addresses" 36 | id = Column(Integer, primary_key=True) 37 | email_address = Column(String, nullable=False) 38 | user_id = Column(Integer, ForeignKey("users.id")) 39 | 40 | user = relationship("User", back_populates="addresses") 41 | 42 | 43 | Base.metadata.create_all(engine) 44 | 45 | 46 | LocalSession = sessionmaker(bind=engine) 47 | 48 | db: Session = LocalSession() 49 | 50 | ed_user = User(name="ed", fullname="Ed Jones", nickname="edsnickname") 51 | 52 | address = Address(email_address="ed@example.com") 53 | address2 = Address(email_address="eddy@example.com") 54 | ed_user.addresses = [address, address2] 55 | db.add(ed_user) 56 | db.commit() 57 | 58 | 59 | def test_defaults() -> None: 60 | PydanticUser = sqlalchemy_to_pydantic(User) 61 | PydanticAddress = sqlalchemy_to_pydantic(Address) 62 | 63 | class PydanticUserWithAddresses(PydanticUser): 64 | addresses: List[PydanticAddress] = [] 65 | 66 | user = db.query(User).first() 67 | pydantic_user = PydanticUser.from_orm(user) 68 | data = pydantic_user.dict() 69 | assert isinstance(data["created"], datetime) 70 | assert isinstance(data["updated"], datetime) 71 | check_data = data.copy() 72 | del check_data["created"] 73 | del check_data["updated"] 74 | assert check_data == { 75 | "fullname": "Ed Jones", 76 | "id": 1, 77 | "name": "ed", 78 | "nickname": "edsnickname", 79 | } 80 | pydantic_user_with_addresses = PydanticUserWithAddresses.from_orm(user) 81 | data = pydantic_user_with_addresses.dict() 82 | assert isinstance(data["updated"], datetime) 83 | assert isinstance(data["created"], datetime) 84 | check_data = data.copy() 85 | del check_data["updated"] 86 | del check_data["created"] 87 | assert check_data == { 88 | "fullname": "Ed Jones", 89 | "id": 1, 90 | "name": "ed", 91 | "nickname": "edsnickname", 92 | "addresses": [ 93 | {"email_address": "ed@example.com", "id": 1, "user_id": 1}, 94 | {"email_address": "eddy@example.com", "id": 2, "user_id": 1}, 95 | ], 96 | } 97 | 98 | 99 | def test_schema() -> None: 100 | PydanticUser = sqlalchemy_to_pydantic(User) 101 | PydanticAddress = sqlalchemy_to_pydantic(Address) 102 | assert PydanticUser.schema() == { 103 | "title": "User", 104 | "type": "object", 105 | "properties": { 106 | "id": {"title": "Id", "type": "integer"}, 107 | "name": {"title": "Name", "type": "string"}, 108 | "fullname": {"title": "Fullname", "type": "string"}, 109 | "nickname": {"title": "Nickname", "type": "string"}, 110 | "created": {"title": "Created", "type": "string", "format": "date-time"}, 111 | "updated": {"title": "Updated", "type": "string", "format": "date-time"}, 112 | }, 113 | "required": ["id"], 114 | } 115 | assert PydanticAddress.schema() == { 116 | "title": "Address", 117 | "type": "object", 118 | "properties": { 119 | "id": {"title": "Id", "type": "integer"}, 120 | "email_address": {"title": "Email Address", "type": "string"}, 121 | "user_id": {"title": "User Id", "type": "integer"}, 122 | }, 123 | "required": ["id", "email_address"], 124 | } 125 | 126 | 127 | def test_config() -> None: 128 | class Config: 129 | orm_mode = True 130 | allow_population_by_field_name = True 131 | 132 | @classmethod 133 | def alias_generator(cls, string: str) -> str: 134 | pascal_case = "".join(word.capitalize() for word in string.split("_")) 135 | camel_case = pascal_case[0].lower() + pascal_case[1:] 136 | return camel_case 137 | 138 | PydanticUser = sqlalchemy_to_pydantic(User) 139 | PydanticAddress = sqlalchemy_to_pydantic(Address, config=Config) 140 | 141 | class PydanticUserWithAddresses(PydanticUser): 142 | addresses: List[PydanticAddress] = [] 143 | 144 | user = db.query(User).first() 145 | pydantic_user_with_addresses = PydanticUserWithAddresses.from_orm(user) 146 | data = pydantic_user_with_addresses.dict(by_alias=True) 147 | assert isinstance(data["created"], datetime) 148 | assert isinstance(data["updated"], datetime) 149 | check_data = data.copy() 150 | del check_data["created"] 151 | del check_data["updated"] 152 | assert check_data == { 153 | "fullname": "Ed Jones", 154 | "id": 1, 155 | "name": "ed", 156 | "nickname": "edsnickname", 157 | "addresses": [ 158 | {"emailAddress": "ed@example.com", "id": 1, "userId": 1}, 159 | {"emailAddress": "eddy@example.com", "id": 2, "userId": 1}, 160 | ], 161 | } 162 | 163 | 164 | def test_exclude() -> None: 165 | PydanticUser = sqlalchemy_to_pydantic(User, exclude={"nickname"}) 166 | PydanticAddress = sqlalchemy_to_pydantic(Address, exclude={"user_id"}) 167 | 168 | class PydanticUserWithAddresses(PydanticUser): 169 | addresses: List[PydanticAddress] = [] 170 | 171 | user = db.query(User).first() 172 | pydantic_user_with_addresses = PydanticUserWithAddresses.from_orm(user) 173 | data = pydantic_user_with_addresses.dict(by_alias=True) 174 | assert isinstance(data["created"], datetime) 175 | assert isinstance(data["updated"], datetime) 176 | check_data = data.copy() 177 | del check_data["created"] 178 | del check_data["updated"] 179 | assert check_data == { 180 | "fullname": "Ed Jones", 181 | "id": 1, 182 | "name": "ed", 183 | "addresses": [ 184 | {"email_address": "ed@example.com", "id": 1}, 185 | {"email_address": "eddy@example.com", "id": 2}, 186 | ], 187 | } 188 | --------------------------------------------------------------------------------