├── .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 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
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 |
--------------------------------------------------------------------------------