💚 Fix linting in CI

Recent PRs have been failing due to an issue with the linting step in the CI - I think caused by [NAME_REDACTED] Pydantic 2.11.

Overview of issues and proposed fixes:

- Elaborate type warning for `get_model_fields` in `_compat.py`.
```
sqlmodel/_compat.py:106: error: Argument 1 to "__get__" of "deprecated_instance_property" has incompatible type "BaseModel | type[BaseModel]"; expected "BaseModel"  [arg-type]
sqlmodel/_compat.py:106: error: Argument 2 to "__get__" of "deprecated_instance_property" has incompatible type "type[BaseModel] | type[type[BaseModel]]"; expected "type[BaseModel]"  [arg-type]
```
I think it's fine to ignore this one?

- `model_fields` in `SQLModelMetaclass` on the other hand, now can do without an ignore warning
- `SQLModel.model_validate()` is supposed to overwrite `BaseModel.model_validate()` but an error gets raised because of the `update` input parameter, which is not present in `BaseModel.model_validate()`. The proposed solution here is to work via `**kwargs`, which I'm generally not a fan of, but the only other option I would see is adding yet another ignore, which feels wrong in this case.
- `SQLModel.model_dump()` is supposed to overwrite `BaseModel.model_dump()` but an error gets raised because of `context` and `alias` having a wrong type, and `fallback` not being present. `fallback` was [introduced](https://github.com/[USER_REDACTED]ntic/pydantic/pull/11398) in Pydantic v2.11. So instead of restricting Pydantic, I suggest to (again) work via `**kwargs`.

The linting test is failing for Python 3.8 in different ways than for all other Python versions, probably because Python 3.8 support was [removed](https://github.com/[USER_REDACTED]ntic/pydantic/pull/11258) for Pydantic 2.11. Accordingly, I've updated the `test.yml` to skip the linting test for Python 3.8. This doesn't feel like a clean solution either, and I guess the other option is to (also) drop support for Python 3.8.

@@ -67,7 +67,7 @@ jobs:
         if: matrix.pydantic-version == 'pydantic-v2'
         run: uv pip install --upgrade "pydantic>=2.0.2,<3.0.0"
       - name: Lint
-        if: matrix.pydantic-version == 'pydantic-v2'
+        if: matrix.pydantic-version == 'pydantic-v2' && matrix.python-version != '3.8'
         run: bash scripts/lint.sh
       - run: mkdir coverage
       - name: Test

@@ -103,7 +103,14 @@ def set_config_value(
         model.model_config[parameter] = value  # type: ignore[literal-required]

     def get_model_fields(model: InstanceOrType[BaseModel]) -> Dict[str, "FieldInfo"]:
-        return model.model_fields
+        # TODO: refactor the usage of this function to always pass the class
+        # not the instance, and then remove this extra check
+        # this is for compatibility with Pydantic v3
+        if isinstance(model, type):
+            use_model = model
+        else:
+            use_model = model.__class__
+        return use_model.model_fields

     def get_fields_set(
         object: InstanceOrType["SQLModel"],

@@ -477,7 +477,7 @@ def Relationship(
 class SQLModelMetaclass(ModelMetaclass, DeclarativeMeta):
     __sqlmodel_relationships__: Dict[str, RelationshipInfo]
     model_config: SQLModelConfig
-    model_fields: Dict[str, FieldInfo]  # type: ignore[assignment]
+    model_fields: ClassVar[Dict[str, FieldInfo]]
     __config__: Type[SQLModelConfig]
     __fields__: Dict[str, ModelField]  # type: ignore[assignment]

@@ -839,7 +839,7 @@ def __tablename__(cls) -> str:
         return cls.__name__.lower()

     [USER_REDACTED]
-    def model_validate(
+    def model_validate(  # type: ignore[override]
         cls: Type[_TSQLModel],
         obj: Any,
         *,
@@ -863,20 +863,25 @@ def model_dump(
         mode: Union[Literal["json", "python"], str] = "python",
         include: Union[IncEx, None] = None,
         exclude: Union[IncEx, None] = None,
-        context: Union[Dict[str, Any], None] = None,
-        by_alias: bool = False,
+        context: Union[Any, None] = None,
+        by_alias: Union[bool, None] = None,
         exclude_unset: bool = False,
         exclude_defaults: bool = False,
         exclude_none: bool = False,
         round_trip: bool = False,
         warnings: Union[bool, Literal["none", "warn", "error"]] = True,
+        fallback: Union[Callable[[Any], Any], None] = None,
         serialize_as_any: bool = False,
     ) -> Dict[str, Any]:
+        if PYDANTIC_MINOR_VERSION < (2, 11):
+            by_alias = by_alias or False
         if PYDANTIC_MINOR_VERSION >= (2, 7):
             extra_kwargs: Dict[str, Any] = {
                 "context": context,
                 "serialize_as_any": serialize_as_any,
             }
+        if PYDANTIC_MINOR_VERSION >= (2, 11):
+            extra_kwargs["fallback"] = fallback
         else:
             extra_kwargs = {}
         if IS_PYDANTIC_V2:
@@ -896,7 +901,7 @@ def model_dump(
             return super().dict(
                 include=include,
                 exclude=exclude,
-                by_alias=by_alias,
+                by_alias=by_alias or False,
                 exclude_unset=exclude_unset,
                 exclude_defaults=exclude_defaults,
                 exclude_none=exclude_none,

Thank you [USER_REDACTED]! :rocket:

I tweaked it a bit mainly to keep autocompletion and inline errors for final users. Although this reveals some areas that probably deserve some tweaking in the future. :thinking:

I added some inline comments to share my mental process.

Thanks! :rocket:

I was checking the error from Pydantic and I saw that they will deprecate accessing the `model_fields` from the instance in Pydantic v3, they should be accessed from the class (which makes sense).

I updated this logic here to handle that future use case. In a subsequent PR we can refactor the internal usage of this function to only pass the class and not the instance.

As the idea is that this should now be only in the class and not instances, I think it's good to type it right away. It won't affect at runtime if people are using it, but will start warning them in their linters, so they can start preparing for this change in Pydantic v3.

I changed this to not use `kwargs` and instead ignore the type here, that way people will keep having autocompletion for the params, and kwargs won't swallow extra / invald params.

Seeing this, I realize it was probably not the best idea to include the extra `update` here. :thinking:

Also, it's probably not needed for most use cases, maybe it would make sense to remove those docs, explain how to extend data to validate an object using just dicts, then deprecate using it, and finally at some point dropping the `update`...

But anyway, for now, it probably works to just `# type: ignore` and handle it on our side so that final users get the best developer experience / editor support.

I like to keep the signature as close to the latest Pydantic, and then do the extra work internally to support the older versions, but this way people can get used to the new syntax and/or use it if they can already upgrade to the latest version.

With this trick, we avoid the type when `by_alias` is `None`. :sweat_smile:

With this we can handle older Pydantic versions but keep the most recent signature.


💚 Fix CI test suite for Windows and MacOS

Run the test suite not only on Ubuntu, but also on Windows and MacOS.

To avoid getting an exponential number of tests, limiting the testing of older Python versions to just a few different combinations. All in all, now the test suite should have 12 combinations to test (3x1x2 + 6), which is the same as before (6x2x1). So it shouldn't be slower, but it should cover more diverse cases.

@@ -25,17 +25,30 @@ jobs:
   test:
     strategy:
       matrix:
-        os: [ ubuntu-latest ]
-        python-version:
-          - "3.8"
-          - "3.9"
-          - "3.10"
-          - "3.11"
-          - "3.12"
-          - "3.13"
+        os: [ ubuntu-latest, windows-latest, macos-latest ]
+        python-version: [ "3.13" ]
         pydantic-version:
           - pydantic-v1
           - pydantic-v2
+        include:
+          - os: macos-latest
+            python-version: "3.8"
+            pydantic-version: pydantic-v1
+          - os: windows-latest
+            python-version: "3.9"
+            pydantic-version: pydantic-v2
+          - os: ubuntu-latest
+            python-version: "3.10"
+            pydantic-version: pydantic-v1
+          - os: macos-latest
+            python-version: "3.11"
+            pydantic-version: pydantic-v2
+          - os: windows-latest
+            python-version: "3.12"
+            pydantic-version: pydantic-v1
+          - os: ubuntu-latest
+            python-version: "3.12"
+            pydantic-version: pydantic-v2
       fail-fast: false
     runs-on: ${{ matrix.os }}
     steps:
@@ -78,7 +91,7 @@ jobs:
       - name: Store coverage files
         uses: actions/upload-artifact@v4
         with:
-          name: coverage-${{ matrix.python-version }}-${{ matrix.pydantic-version }}
+          name: coverage-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.pydantic-version }}
           path: coverage
           include-hidden-files: true

@@ -81,6 +81,7 @@ source = [
 ]
 context = '${CONTEXT}'
 dynamic_context = "test_function"
+relative_files = true

 [tool.coverage.report]
 show_missing = true

@@ -13,7 +13,7 @@ def test_select_gen() -> None:
     env = os.environ.copy()
     env["CHECK_JINJA"] = "1"
     result = subprocess.run(
-        [sys.executable, "scripts/generate_select.py"],
+        [sys.executable, Path("scripts") / "generate_select.py"],
         env=env,
         check=True,
         cwd=root_path,

Niceee! Thank you! 🙌 🚀

The coverage test kept on giving this error for either MacOS or Windows:
```
No source for code: '/Users/runner/work/sqlmodel/sqlmodel/docs_src/__init__.py'.
```
This is fixed by [NAME_REDACTED] `relative_files` setting 🎉

The `env` stuff is basically the same fix as what was done in https://github.com/fastapi/sqlmodel/pull/969, but shortened.

(we can probably also just keep the current way it's done on `main` - as long as the things in `os.environ` are passed to `env` as well)


👷 Add retries to Smokeshow

Similar to https://github.com/fastapi/fastapi/pull/13151

@@ -35,7 +35,17 @@ jobs:
           path: htmlcov
           github-token: ${{ secrets.GITHUB_TOKEN }}
           run-id: ${{ github.event.workflow_run.id }}
-      - run: smokeshow upload htmlcov
+      # Try 5 times to upload coverage to smokeshow
+      - name: Upload coverage to Smokeshow
+        run: |
+          for i in 1 2 3 4 5; do
+            if smokeshow upload htmlcov; then
+                echo "Smokeshow upload success!"
+                break
+            fi
+            echo "Smokeshow upload error, sleep 1 sec and try again."
+            sleep 1
+          done
         env:
           SMOKESHOW_GITHUB_STATUS_DESCRIPTION: Coverage {coverage-percentage}
           SMOKESHOW_GITHUB_COVERAGE_THRESHOLD: 95

Nice! Thanks! :rocket:

This is just to pass the CI for now. In fact, I want to fix the test suite so that it runs again for Python 3.7 (see #1290 and #1301) but I'm running into issues with Smokeshow apparently hanging, and I want to see whether these retries will fix that. Once this is merged, I'll add support for 3.7 back into the test suite in #1301.