Skip to content

Commit f967853

Browse files
authored
👷 Add support for Pydantic v1 (#116)
* 👷 Run tests on Pydantic v1 * T * Add pydantic compat * More compat * Re add cache
1 parent 9582765 commit f967853

File tree

7 files changed

+112
-29
lines changed

7 files changed

+112
-29
lines changed

‎.github/workflows/test.yml‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,23 @@ on:
2323

2424
jobs:
2525
test:
26-
runs-on: ubuntu-latest
2726
strategy:
2827
matrix:
29-
python-version:
30-
- "3.8"
31-
- "3.9"
32-
- "3.10"
33-
- "3.11"
34-
- "3.12"
28+
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12", "3.13", "3.14"]
29+
pydantic-version: ["v2"]
30+
include:
31+
- python-version: "3.8"
32+
pydantic-version: "v1"
33+
- python-version: "3.9"
34+
pydantic-version: "v1"
35+
- python-version: "3.10"
36+
pydantic-version: "v1"
37+
- python-version: "3.11"
38+
pydantic-version: "v1"
39+
- python-version: "3.12"
40+
pydantic-version: "v1"
3541
fail-fast: false
42+
runs-on: ubuntu-latest
3643
steps:
3744
- name: Dump GitHub context
3845
env:
@@ -50,7 +57,7 @@ jobs:
5057
id: cache
5158
with:
5259
path: ${{ env.pythonLocation }}
53-
key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml', 'requirements-tests.txt') }}
60+
key: ${{ runner.os }}-python-${{ env.pythonLocation }}-${{ hashFiles('pyproject.toml', 'requirements-tests.txt') }}-{{ matrix.pydantic-version }}
5461
# Allow debugging with tmate
5562
- name: Setup tmate session
5663
uses: mxschmitt/action-tmate@v3
@@ -60,6 +67,9 @@ jobs:
6067
- name: Install Dependencies
6168
if: steps.cache.outputs.cache-hit != 'true'
6269
run: pip install -r requirements-tests.txt
70+
- name: Install Pydantic v1
71+
if: matrix.pydantic-version == 'v1'
72+
run: pip install "pydantic<2.0.0"
6373
- name: Lint
6474
run: bash scripts/lint.sh
6575
- run: mkdir coverage
@@ -71,7 +81,7 @@ jobs:
7181
- name: Store coverage files
7282
uses: actions/upload-artifact@v4
7383
with:
74-
name: coverage-${{ matrix.python-version }}
84+
name: coverage-${{ matrix.python-version }}-${{ matrix.pydantic-version }}
7585
path: coverage
7686
include-hidden-files: true
7787

‎src/fastapi_cloud_cli/commands/deploy.py‎

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import rignore
1414
import typer
1515
from httpx import Client
16-
from pydantic import BaseModel, EmailStr, TypeAdapter, ValidationError
16+
from pydantic import BaseModel, EmailStr, ValidationError
1717
from rich.text import Text
1818
from rich_toolkit import RichToolkit
1919
from rich_toolkit.menu import Option
@@ -24,6 +24,11 @@
2424
from fastapi_cloud_cli.utils.apps import AppConfig, get_app_config, write_app_config
2525
from fastapi_cloud_cli.utils.auth import is_logged_in
2626
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
27+
from fastapi_cloud_cli.utils.pydantic_compat import (
28+
TypeAdapter,
29+
model_dump,
30+
model_validate,
31+
)
2732

2833
logger = logging.getLogger(__name__)
2934

@@ -91,7 +96,7 @@ def _get_teams() -> List[Team]:
9196

9297
data = response.json()["data"]
9398

94-
return [Team.model_validate(team) for team in data]
99+
return [model_validate(Team, team) for team in data]
95100

96101

97102
class AppResponse(BaseModel):
@@ -108,7 +113,7 @@ def _create_app(team_id: str, app_name: str) -> AppResponse:
108113

109114
response.raise_for_status()
110115

111-
return AppResponse.model_validate(response.json())
116+
return model_validate(AppResponse, response.json())
112117

113118

114119
class DeploymentStatus(str, Enum):
@@ -161,7 +166,7 @@ def _create_deployment(app_id: str) -> CreateDeploymentResponse:
161166
response = client.post(f"/apps/{app_id}/deployments/")
162167
response.raise_for_status()
163168

164-
return CreateDeploymentResponse.model_validate(response.json())
169+
return model_validate(CreateDeploymentResponse, response.json())
165170

166171

167172
class RequestUploadResponse(BaseModel):
@@ -186,7 +191,7 @@ def _upload_deployment(deployment_id: str, archive_path: Path) -> None:
186191
response = fastapi_client.post(f"/deployments/{deployment_id}/upload")
187192
response.raise_for_status()
188193

189-
upload_data = RequestUploadResponse.model_validate(response.json())
194+
upload_data = model_validate(RequestUploadResponse, response.json())
190195
logger.debug("Received upload URL: %s", upload_data.url)
191196

192197
# Upload the archive
@@ -221,7 +226,7 @@ def _get_app(app_slug: str) -> Optional[AppResponse]:
221226

222227
data = response.json()
223228

224-
return AppResponse.model_validate(data)
229+
return model_validate(AppResponse, data)
225230

226231

227232
def _get_apps(team_id: str) -> List[AppResponse]:
@@ -231,7 +236,7 @@ def _get_apps(team_id: str) -> List[AppResponse]:
231236

232237
data = response.json()["data"]
233238

234-
return [AppResponse.model_validate(app) for app in data]
239+
return [model_validate(AppResponse, app) for app in data]
235240

236241

237242
def _stream_build_logs(deployment_id: str) -> Generator[str, None, None]:
@@ -416,9 +421,7 @@ def _send_waitlist_form(
416421
with toolkit.progress("Sending your request...") as progress:
417422
with APIClient() as client:
418423
with handle_http_errors(progress):
419-
response = client.post(
420-
"/users/waiting-list", json=result.model_dump(mode="json")
421-
)
424+
response = client.post("/users/waiting-list", json=model_dump(result))
422425

423426
response.raise_for_status()
424427

@@ -443,7 +446,7 @@ def _waitlist_form(toolkit: RichToolkit) -> None:
443446

444447
toolkit.print_line()
445448

446-
result = SignupToWaitingList(email=email)
449+
result = model_validate(SignupToWaitingList, {"email": email})
447450

448451
if toolkit.confirm(
449452
"Do you want to get access faster by giving us more information?",
@@ -467,11 +470,12 @@ def _waitlist_form(toolkit: RichToolkit) -> None:
467470
result = form.run() # type: ignore
468471

469472
try:
470-
result = SignupToWaitingList.model_validate(
473+
result = model_validate(
474+
SignupToWaitingList,
471475
{
472476
"email": email,
473477
**result, # type: ignore
474-
}
478+
},
475479
)
476480
except ValidationError:
477481
toolkit.print(

‎src/fastapi_cloud_cli/commands/env.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from fastapi_cloud_cli.utils.auth import is_logged_in
1212
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
1313
from fastapi_cloud_cli.utils.env import validate_environment_variable_name
14+
from fastapi_cloud_cli.utils.pydantic_compat import model_validate
1415

1516
logger = logging.getLogger(__name__)
1617

@@ -29,7 +30,7 @@ def _get_environment_variables(app_id: str) -> EnvironmentVariableResponse:
2930
response = client.get(f"/apps/{app_id}/environment-variables/")
3031
response.raise_for_status()
3132

32-
return EnvironmentVariableResponse.model_validate(response.json())
33+
return model_validate(EnvironmentVariableResponse, response.json())
3334

3435

3536
def _delete_environment_variable(app_id: str, name: str) -> bool:

‎src/fastapi_cloud_cli/commands/login.py‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
write_auth_config,
1717
)
1818
from fastapi_cloud_cli.utils.cli import get_rich_toolkit, handle_http_errors
19+
from fastapi_cloud_cli.utils.pydantic_compat import model_validate_json
1920

2021
logger = logging.getLogger(__name__)
2122

@@ -43,7 +44,7 @@ def _start_device_authorization(
4344

4445
response.raise_for_status()
4546

46-
return AuthorizationData.model_validate(response.json())
47+
return model_validate_json(AuthorizationData, response.text)
4748

4849

4950
def _fetch_access_token(client: httpx.Client, device_code: str, interval: int) -> str:
@@ -73,7 +74,7 @@ def _fetch_access_token(client: httpx.Client, device_code: str, interval: int) -
7374

7475
time.sleep(interval)
7576

76-
response_data = TokenResponse.model_validate(response.json())
77+
response_data = model_validate_json(TokenResponse, response.text)
7778

7879
return response_data.access_token
7980

‎src/fastapi_cloud_cli/utils/apps.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
from pydantic import BaseModel
66

7+
from fastapi_cloud_cli.utils.pydantic_compat import model_dump_json, model_validate_json
8+
79
logger = logging.getLogger("fastapi_cli")
810

911

@@ -21,7 +23,7 @@ def get_app_config(path_to_deploy: Path) -> Optional[AppConfig]:
2123
return None
2224

2325
logger.debug("App config loaded successfully")
24-
return AppConfig.model_validate_json(config_path.read_text(encoding="utf-8"))
26+
return model_validate_json(AppConfig, config_path.read_text(encoding="utf-8"))
2527

2628

2729
README = """
@@ -50,7 +52,7 @@ def write_app_config(path_to_deploy: Path, app_config: AppConfig) -> None:
5052
config_path.parent.mkdir(parents=True, exist_ok=True)
5153

5254
config_path.write_text(
53-
app_config.model_dump_json(),
55+
model_dump_json(app_config),
5456
encoding="utf-8",
5557
)
5658
readme_path.write_text(README, encoding="utf-8")

‎src/fastapi_cloud_cli/utils/auth.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
from pydantic import BaseModel
99

10+
from fastapi_cloud_cli.utils.pydantic_compat import model_dump_json, model_validate_json
11+
1012
from .config import get_auth_path
1113

1214
logger = logging.getLogger("fastapi_cli")
@@ -20,7 +22,7 @@ def write_auth_config(auth_data: AuthConfig) -> None:
2022
auth_path = get_auth_path()
2123
logger.debug("Writing auth config to: %s", auth_path)
2224

23-
auth_path.write_text(auth_data.model_dump_json(), encoding="utf-8")
25+
auth_path.write_text(model_dump_json(auth_data), encoding="utf-8")
2426
logger.debug("Auth config written successfully")
2527

2628

@@ -44,7 +46,7 @@ def read_auth_config() -> Optional[AuthConfig]:
4446
return None
4547

4648
logger.debug("Auth config loaded successfully")
47-
return AuthConfig.model_validate_json(auth_path.read_text(encoding="utf-8"))
49+
return model_validate_json(AuthConfig, auth_path.read_text(encoding="utf-8"))
4850

4951

5052
def get_auth_token() -> Optional[str]:
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
from typing import Any, Dict, Generic, Type, TypeVar
2+
3+
from pydantic import BaseModel
4+
from pydantic.version import VERSION as PYDANTIC_VERSION
5+
6+
PYDANTIC_VERSION_MINOR_TUPLE = tuple(int(x) for x in PYDANTIC_VERSION.split(".")[:2])
7+
PYDANTIC_V2 = PYDANTIC_VERSION_MINOR_TUPLE[0] == 2
8+
9+
10+
T = TypeVar("T")
11+
Model = TypeVar("Model", bound=BaseModel)
12+
13+
14+
def model_validate(model_class: Type[Model], data: Dict[Any, Any]) -> Model:
15+
if PYDANTIC_V2:
16+
return model_class.model_validate(data) # type: ignore[no-any-return, unused-ignore, attr-defined]
17+
else:
18+
return model_class.parse_obj(data) # type: ignore[no-any-return, unused-ignore, attr-defined]
19+
20+
21+
def model_validate_json(model_class: Type[Model], data: str) -> Model:
22+
if PYDANTIC_V2:
23+
return model_class.model_validate_json(data) # type: ignore[no-any-return, unused-ignore, attr-defined]
24+
else:
25+
return model_class.parse_raw(data) # type: ignore[no-any-return, unused-ignore, attr-defined]
26+
27+
28+
def model_dump(obj: BaseModel, **kwargs: Any) -> Dict[Any, Any]:
29+
if PYDANTIC_V2:
30+
return obj.model_dump(**kwargs) # type: ignore[no-any-return, unused-ignore, attr-defined]
31+
else:
32+
return obj.dict(**kwargs) # type: ignore[no-any-return, unused-ignore, attr-defined]
33+
34+
35+
def model_dump_json(obj: BaseModel) -> str:
36+
if PYDANTIC_V2:
37+
return obj.model_dump_json() # type: ignore[no-any-return, unused-ignore, attr-defined]
38+
else:
39+
# Use compact separators to match Pydantic v2's output format
40+
return obj.json(separators=(",", ":")) # type: ignore[no-any-return, unused-ignore, attr-defined]
41+
42+
43+
class TypeAdapter(Generic[T]):
44+
def __init__(self, type_: Type[T]) -> None:
45+
self.type_ = type_
46+
47+
if PYDANTIC_V2:
48+
from pydantic import ( # type: ignore[attr-defined, unused-ignore]
49+
TypeAdapter as PydanticTypeAdapter,
50+
)
51+
52+
self._adapter = PydanticTypeAdapter(type_)
53+
else:
54+
self._adapter = None # type: ignore[assignment, unused-ignore]
55+
56+
def validate_python(self, value: Any) -> T:
57+
"""Validate a Python object against the type."""
58+
if PYDANTIC_V2:
59+
return self._adapter.validate_python(value) # type: ignore[no-any-return, union-attr, unused-ignore]
60+
else:
61+
from pydantic import parse_obj_as
62+
63+
return parse_obj_as(self.type_, value) # type: ignore[no-any-return, unused-ignore]

0 commit comments

Comments
 (0)