Repository: gtalarico/pyairtable
Pull request #288 (2023-08-01 18:12:58)

Workaround for pydantic v2
It's a bit heavier than expected to make mypy happy on both pydantic versions.

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/gtalarico/pyairtable/pull/288?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) Report
> Merging [#288](https://app.codecov.io/gh/gtalarico/pyairtable/pull/288?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) (35914b4) into [main](https://app.codecov.io/gh/gtalarico/pyairtable/commit/f203ac90a65ab6d2e6e909e99c953b32665b3254?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) (f203ac9) will **decrease** coverage by `0.60%`.
> The diff coverage is `62.50%`.

```diff
@@            Coverage Diff             @@
##             main     #288      +/-   ##
==========================================
- Coverage   95.80%   95.20%   -0.60%     
==========================================
  Files          19       19              
  Lines         929      939      +10     
==========================================
+ Hits          890      894       +4     
- Misses         39       45       +6     
```


| [Files Changed](https://app.codecov.io/gh/gtalarico/pyairtable/pull/288?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico) | Coverage Δ | |
|---|---|---|
| [pyairtable/api/types.py](https://app.codecov.io/gh/gtalarico/pyairtable/pull/288?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico#diff-cHlhaXJ0YWJsZS9hcGkvdHlwZXMucHk=) | `96.15% <62.50%> (-3.85%)` | :arrow_down: |
| [pyairtable/models/\_base.py](https://app.codecov.io/gh/gtalarico/pyairtable/pull/288?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Gui+Talarico#diff-cHlhaXJ0YWJsZS9tb2RlbHMvX2Jhc2UucHk=) | `92.00% <62.50%> (-5.78%)` | :arrow_down: |


xl0: Agree re tox, I'll add it later today.
xl0: @mesozoic I updated the PR to
- Add -pydantic1 to tox tests. Also added mypy-pydantic1.
- Moves pydantic stuff into pyairtable/pydantic.py. Can't be in utils due to circular dependency.
xl0: @mesozoic , any idea what's up with the docs failing?
mesozoic: > @mesozoic , any idea what's up with the docs failing?

I think you might've forgotten to `git add` the new file. Can we call it `_compat.py`, though, to (1) make it clear that it's internal and not part of the public API, and (2) to avoid any confusion arising from the module and its contents having the same name?
xl0: @mesozoic , heh, my bad. Ok, renamed it to _compat.py.
mesozoic: @xl0 One more request, could you rebase the branch with [signed commits](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification)? Easiest way is with SSH, in my experience, but the repo requires it before we merge. I could rebase and merge it on your behalf, but then you won't get any credit. 😁 
xl0: Ok, I'll check it later today.

Patch:
diff --git a/pyairtable/_compat.py b/pyairtable/_compat.py
new file mode 100644
index 0000000..dbc06d8
--- /dev/null
+++ b/pyairtable/_compat.py
@@ -0,0 +1,12 @@
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:  # mypy really does not like this conditional import.
+    import pydantic as pydantic
+else:
+    # Pydantic v2 broke a bunch of stuff. Luckily they provide a built-in v1.
+    try:
+        import pydantic.v1 as pydantic
+    except ImportError:
+        import pydantic
+
+__all__ = ["pydantic"]
diff --git a/pyairtable/api/types.py b/pyairtable/api/types.py
index 2570f08..65d8eb9 100644
--- a/pyairtable/api/types.py
+++ b/pyairtable/api/types.py
@@ -5,9 +5,10 @@ and return values to various pyAirtable methods.
 from functools import lru_cache
 from typing import Any, Dict, List, Optional, Type, TypeVar, Union, cast
 
-import pydantic
 from typing_extensions import Required, TypeAlias, TypedDict
 
+from pyairtable._compat import pydantic
+
 T = TypeVar("T")
 
 #: An alias for ``str`` used internally for disambiguation.
@@ -287,7 +288,8 @@ def _create_model_from_typeddict(cls: Type[T]) -> Type[pydantic.BaseModel]:
     Creates a pydantic model from a TypedDict to use as a validator.
     Memoizes the result so we don't have to call this more than once per class.
     """
-    return pydantic.create_model_from_typeddict(cls)
+    # Mypy can't tell that we are using pydantic v1.
+    return pydantic.create_model_from_typeddict(cls)  # type: ignore[no-any-return, operator, unused-ignore]
 
 
 def assert_typed_dict(cls: Type[T], obj: Any) -> T:
diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py
index 5317c9e..c3a84ca 100644
--- a/pyairtable/models/_base.py
+++ b/pyairtable/models/_base.py
@@ -2,9 +2,10 @@ from functools import partial
 from typing import Any, ClassVar, Iterable, Optional
 
 import inflection
-import pydantic
 from typing_extensions import Self as SelfType
 
+from pyairtable._compat import pydantic
+
 
 class AirtableModel(pydantic.BaseModel):
     """
@@ -86,7 +87,8 @@ class SerializableModel(AirtableModel):
 
     def __setattr__(self, name: str, value: Any) -> None:
         # Prevents implementers from changing values on readonly or non-writable fields.
-        if name in self.__class__.__fields__:
+        # Mypy can't tell that we are using pydantic v1.
+        if name in self.__class__.__fields__:  # type: ignore[operator, unused-ignore]
             if self.__readonly__ and name in self.__readonly__:
                 raise AttributeError(name)
             if self.__writable__ is not None and name not in self.__writable__:
diff --git a/setup.cfg b/setup.cfg
index f7b3923..fd968f1 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -30,7 +30,7 @@ classifiers =
 packages = find:
 install_requires =
     inflection
-    pydantic ~= 1.10
+    pydantic
     requests >= 2.22.0
     typing_extensions
     urllib3 >= 1.26
diff --git a/tox.ini b/tox.ini
index 584ae4d..44bbe5f 100644
--- a/tox.ini
+++ b/tox.ini
@@ -2,7 +2,9 @@
 envlist =
     pre-commit
     mypy
+    mypy-pydantic1
     py3{8,9,10,11}-requests{min,max}
+    py3{8,9,10,11}-pydantic1
     coverage
 
 [gh-actions]
@@ -29,6 +31,7 @@ deps =
     -r requirements-test.txt
     requestsmin: requests==2.22.0  # Keep in sync with setup.cfg
     requestsmax: requests>=2.22.0  # Keep in sync with setup.cfg
+    pydantic1: pydantic<2  # Lots of projects still use 1.x
 
 [testenv:coverage]
 passenv = COVERAGE_FORMAT


Test patch:
diff --git a/tests/test_api_types.py b/tests/test_api_types.py
index 35ddb6f..e124510 100644
--- a/tests/test_api_types.py
+++ b/tests/test_api_types.py
@@ -1,6 +1,6 @@
 import pytest
-from pydantic import ValidationError
 
+from pyairtable._compat import pydantic
 from pyairtable.api import types as T
 from pyairtable.testing import fake_attachment, fake_id, fake_record, fake_user
 
@@ -53,9 +53,9 @@ def test_assert_not_typed_dict(cls, value):
     """
     Test that we can assert that a dict does *not* conform to a TypedDict.
     """
-    with pytest.raises(ValidationError):
+    with pytest.raises(pydantic.ValidationError):
         T.assert_typed_dict(cls, value)
-    with pytest.raises(ValidationError):
+    with pytest.raises(pydantic.ValidationError):
         T.assert_typed_dicts(cls, [value])