Repository: gtalarico/pyairtable (python)

Issue:
Incompatible with Python 3.13
### Bug Report

#### Description

I encountered a `TypeError` when trying to import the `pyairtable` API using `from pyairtable import Api`. The error traceback indicates a conflict with multiple base classes in the `Comment` class of the `pyairtable` package.

This is my first time using and installing the `pyairtable` library, and I'm working in a Jupyter notebook within a virtual environment. I followed the installation instructions from the documentation, but the error occurred during the import.

#### Code to Reproduce

```python
from pyairtable import Api
```
#### Error Output
```
---------------------------------------------------------------------------  
TypeError Traceback (most recent call last)  
...  
TypeError: multiple bases have instance lay-out conflict  

#### Traceback (Full)
File "c:\path\to\your\project\pyairtable\__init__.py", line 3  
    from .api import Api, Base, Table  
File "c:\path\to\your\project\pyairtable\api\api.py", line 10  
    from pyairtable.api.enterprise import Enterprise  
File "c:\path\to\your\project\pyairtable\models\comment.py", line 7  
    class Comment(CanUpdateModel, CanDeleteModel, ...):  
File "c:\path\to\your\project\pydantic\v1\main.py", line 282, in ModelMetaclass.__new__  
    cls = super().__new__(mcs, name, bases, new_namespace, **kwargs)  
File <frozen abc>, line 106, in __new__  
    TypeError: multiple bases have instance lay-out conflict  
```

#### Environment

- **Python version**: 3.13.0
- **pyairtable version**: 2.3.3
- **pydantic version**: 2.9.2
- **typing_extensions version**: 4.12.2
- **Jupyter notebook**: Yes
- **Virtual environment**: Yes (created using `venv`)

#### Steps to Reproduce

1. Set up a virtual environment and install `pyairtable` with `pip install pyairtable`.
2. Launch a Jupyter notebook.
3. Try to import the `Api` class from `pyairtable` using `from pyairtable import Api`.
4. The above error occurs upon import.

#### Expected Behavior

The library should import without any errors.

#### Installed Libraries

Here is the list of installed libraries in my virtual environment:
annotated-types==0.7.0
asttokens==2.4.1
certifi==2024.8.30
charset-normalizer==3.4.0
colorama==0.4.6
comm==0.2.2
debugpy==1.8.7
decorator==5.1.1
executing==2.1.0
idna==3.10
inflection==0.5.1
ipykernel==6.29.5
ipython==8.28.0
jedi==0.19.1
jupyter_client==8.6.3
jupyter_core==5.7.2
matplotlib-inline==0.1.7
nest-asyncio==1.6.0
packaging==24.1
parso==0.8.4
platformdirs==4.3.6
prompt_toolkit==3.0.48
psutil==6.0.0
pure_eval==0.2.3
pyairtable==2.3.3
pydantic==2.9.2
pydantic_core==2.23.4
Pygments==2.18.0
python-dateutil==2.9.0.post0
pywin32==308
pyzmq==26.2.0
requests==2.32.3
six==1.16.0
stack-data==0.6.3
tornado==6.4.1
traitlets==5.14.3
typing_extensions==4.12.2
urllib3==2.2.3
wcwidth==0.2.13


Pull request:
Release 2.3.4
This will fix #394 for the 2.x version of the library.

Patch:
diff --git a/docs/source/changelog.rst b/docs/source/changelog.rst
index e990f8d..820e6af 100644
--- a/docs/source/changelog.rst
+++ b/docs/source/changelog.rst
@@ -2,6 +2,12 @@
 Changelog
 =========
 
+2.3.4 (2024-10-21)
+------------------------
+
+* Fixed a crash at import time under Python 3.13.
+  - `PR #396 <https://github.com/gtalarico/pyairtable/pull/396>`_.
+
 2.3.3 (2024-03-22)
 ------------------------
 
diff --git a/pyairtable/formulas.py b/pyairtable/formulas.py
index 734a4e4..f7360e4 100644
--- a/pyairtable/formulas.py
+++ b/pyairtable/formulas.py
@@ -78,7 +78,7 @@ def escape_quotes(value: str) -> str:
     return escaped_value
 
 
-def to_airtable_value(value: Any) -> Any:
+def to_airtable_value(value: Any) -> Any:  # pragma: no cover
     """
     Cast value to appropriate airtable types and format.
     For example, to check ``bool`` values in formulas, you actually to compare
diff --git a/pyairtable/models/_base.py b/pyairtable/models/_base.py
index 479d201..1776181 100644
--- a/pyairtable/models/_base.py
+++ b/pyairtable/models/_base.py
@@ -24,9 +24,6 @@ class AirtableModel(pydantic.BaseModel):
         # Allow both base_invite_links= and baseInviteLinks= in constructor
         allow_population_by_field_name = True
 
-        # We'll assume this in a couple different places
-        underscore_attrs_are_private = True
-
     _raw: Any = pydantic.PrivateAttr()
 
     def __init__(self, **data: Any) -> None:
@@ -125,7 +122,7 @@ class RestfulModel(AirtableModel):
 
     _api: "pyairtable.api.api.Api" = pydantic.PrivateAttr()
     _url: str = pydantic.PrivateAttr(default="")
-    _url_context: Any = None
+    _url_context: Any = pydantic.PrivateAttr(default=None)
 
     def __init_subclass__(cls, **kwargs: Any) -> None:
         cls.__url_pattern = kwargs.pop("url", cls.__url_pattern)
diff --git a/pyairtable/orm/fields.py b/pyairtable/orm/fields.py
index 2c5cc9d..96caec9 100644
--- a/pyairtable/orm/fields.py
+++ b/pyairtable/orm/fields.py
@@ -421,7 +421,7 @@ class _ListField(Generic[T_API, T_ORM], Field[List[T_API], List[T_ORM]]):
 
     def to_internal_value(self, value: Optional[List[T_ORM]]) -> List[T_ORM]:
         if value is None:
-            value = []
+            value = []  # pragma: no cover
         return value
 
 
diff --git a/pyairtable/utils.py b/pyairtable/utils.py
index b89297e..90457ea 100644
--- a/pyairtable/utils.py
+++ b/pyairtable/utils.py
@@ -25,6 +25,8 @@ from pyairtable.api.types import CreateAttachmentDict
 P = ParamSpec("P")
 R = TypeVar("R", covariant=True)
 T = TypeVar("T")
+C = TypeVar("C", contravariant=True)
+F = TypeVar("F", bound=Callable[..., Any])
 
 
 def datetime_to_iso_str(value: datetime) -> str:
@@ -141,9 +143,6 @@ is_field_id = partial(is_airtable_id, prefix="fld")
 is_user_id = partial(is_airtable_id, prefix="usr")
 
 
-F = TypeVar("F", bound=Callable[..., Any])
-
-
 def enterprise_only(wrapped: F, /, modify_docstring: bool = True) -> F:
     """
     Wrap a function or method so that if Airtable returns a 404,
@@ -193,13 +192,13 @@ def _append_docstring_text(obj: Any, text: str) -> None:
     obj.__doc__ = f"{doc}\n\n{text}"
 
 
-class FetchMethod(Protocol, Generic[R]):
-    def __get__(self, instance: Any, owner: Any) -> Callable[..., R]: ...
+class FetchMethod(Protocol, Generic[C, R]):
+    def __get__(self, instance: C, owner: Any) -> Callable[..., R]: ...
 
-    def __call__(self_, self: Any, *, force: bool = False) -> R: ...
+    def __call__(self_, self: C, *, force: bool = False) -> R: ...
 
 
-def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]:
+def cache_unless_forced(func: Callable[[C], R]) -> FetchMethod[C, R]:
     """
     Wrap a method (e.g. ``Base.shares()``) in a decorator that will save
     a memoized version of the return value for future reuse, but will also
@@ -211,7 +210,7 @@ def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]:
         attr = "_cached_" + attr.lstrip("_")
 
     @wraps(func)
-    def _inner(self: Any, *, force: bool = False) -> R:
+    def _inner(self: C, *, force: bool = False) -> R:
         if force or getattr(self, attr, None) is None:
             setattr(self, attr, func(self))
         return cast(R, getattr(self, attr))
@@ -219,7 +218,7 @@ def cache_unless_forced(func: Callable[P, R]) -> FetchMethod[R]:
     _inner.__annotations__["force"] = bool
     _append_docstring_text(_inner, "Args:\n\tforce: |kwarg_force_metadata|")
 
-    return _inner
+    return cast(FetchMethod[C, R], _inner)
 
 
 def coerce_iso_str(value: Any) -> Optional[str]:
diff --git a/tox.ini b/tox.ini
index 5f14a01..75296ce 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,23 +1,32 @@
 [tox]
 envlist =
     pre-commit
-    mypy
-    py3{8,9,10,11,12}{,-pydantic1,-requestsmin}
+    mypy-py3{8,9,10,11,12,13}
+    py3{8,9,10,11,12,13}{,-pydantic1,-requestsmin}
+    integration
     coverage
 
 [gh-actions]
 python =
-    3.8: py38, coverage
-    3.9: py39
-    3.10: py310
-    3.11: py311
-    3.12: py312
+    3.8: py38, mypy-py38
+    3.9: py39, mypy-py39
+    3.10: py310, mypy-py310
+    3.11: py311, mypy-py311
+    3.12: coverage, mypy-py312
+    3.13: py313, mypy-py313
 
 [testenv:pre-commit]
 deps = pre-commit
 commands = pre-commit run --all-files
 
-[testenv:mypy]
+[testenv:mypy,mypy-py3{8,9,10,11,12,13}]
+basepython =
+    py38: python3.8
+    py39: python3.9
+    py310: python3.10
+    py311: python3.11
+    py312: python3.12
+    py313: python3.13
 deps = -r requirements-dev.txt
 commands = mypy --strict pyairtable tests/test_typing.py
 
@@ -27,16 +36,21 @@ passenv =
     AIRTABLE_ENTERPRISE_ID
 addopts = -v
 testpaths = tests
-commands = python -m pytest {posargs}
+commands = python -m pytest {posargs:-m 'not integration'}
+extras = cli
 deps =
     -r requirements-test.txt
     requestsmin: requests==2.22.0  # Keep in sync with setup.cfg
     pydantic1: pydantic<2  # Lots of projects still use 1.x
 
+[testenv:integration]
+commands =
+    python -m pytest -m integration
+
 [testenv:coverage]
 passenv = COVERAGE_FORMAT
 commands =
-    python -m pytest -m 'not integration' --cov=pyairtable --cov-report={env:COVERAGE_FORMAT:html}
+    python -m pytest -m 'not integration' --cov=pyairtable --cov-report={env:COVERAGE_FORMAT:html} --cov-fail-under=100
 
 [testenv:docs]
 basepython = python3.8
@@ -47,6 +61,7 @@ commands =
     python -m sphinx -T -E -b html {toxinidir}/docs/source {toxinidir}/docs/build
 
 [pytest]
+requests_mock_case_sensitive = true
 markers =
     integration: integration tests, hit airtable api
 
@@ -54,7 +69,7 @@ markers =
 filename = *.py
 count = True
 # See https://black.readthedocs.io/en/stable/the_black_code_style/current_style.html
-ignore = E203, E266, E501, E704, W503
+ignore = E203, E226, E266, E501, E704, W503
 select = B,C,E,F,W,T4,B9
 max-line-length = 88
 max-complexity = 15


Test patch:
diff --git a/.github/workflows/test_lint_deploy.yml b/.github/workflows/test_lint_deploy.yml
index cc89d89..d5b53ee 100644
--- a/.github/workflows/test_lint_deploy.yml
+++ b/.github/workflows/test_lint_deploy.yml
@@ -11,7 +11,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python: ["3.8", "3.9", "3.10", "3.11", "3.12"]
+        python: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13"]
 
     steps:
       - uses: actions/checkout@v3
diff --git a/tests/test_api_enterprise.py b/tests/test_api_enterprise.py
index c435bb2..e52c676 100644
--- a/tests/test_api_enterprise.py
+++ b/tests/test_api_enterprise.py
@@ -223,7 +223,7 @@ def test_audit_log__sortorder(
         list(enterprise.audit_log(*fncall.args, **fncall.kwargs))
 
     request = enterprise_mocks.get_audit_log.last_request
-    assert request.qs["sortorder"] == [sortorder]
+    assert request.qs["sortOrder"] == [sortorder]
     assert m.mock_calls[-1].kwargs["offset_field"] == offset_field