Skip to content

Commit 81ee194

Browse files
authored
refactor!: Introduce fully typed clients (#604)
### Summary This is a major refactoring that introduces fully typed Pydantic models throughout the client library. The models are generated from the OpenAPI specifications. All API responses now return typed objects instead of raw dictionaries. This follows up on apify/apify-docs#2182. ### Issues - Closes: #21 - Closes: #481 ### Packages - Add direct dependency on `Pydantic`. - Removes the dependency on `apify-shared`. - Add dev dependency [datamodel-code-generator](https://koxudaxi.github.io/datamodel-code-generator/) for model generation. ### Key changes - Uses [datamodel-code-generator](https://koxudaxi.github.io/datamodel-code-generator/) tool configured via `pyproject.toml` to generate Pydantic models based on the [OpenAPI specs](https://docs.apify.com/api/openapi.json). - Refactors the whole codebase to adopt the new generated models. - All resource clients now return typed Pydantic models (`Actor`, `Task`, `Run`, etc.). - Adds response wrappers for validating and extracting API response data. - Updates list methods to return typed pagination models. - Documentation examples now use typed attribute access. - Updates the SDK to use the new typed client. - See the corresponding PR in `apify/apify-sdk-python` for details - apify/apify-sdk-python#719. - It will be merged later. ### Architecture - Get rid of 3/4/5 levels of inheritance. - Get rid of inline imports because of circular dependencies. - I had to utilize `ClientRegistry` to be able to achieve that (because of resource clients-siblings imports). ### Breaking changes - Client methods now return Pydantic models instead of dicts. - Access patterns change from dict-style (`result['key']`) to attribute-style (`result.key`). ### Test plan - Updated test concurrency to 16 workers. - A lot of new tests were implemented - coverage ~95%. - Unit tests - do not call production API, only for testing utils or other functionality using mocks. - Integration tests - call production API. - Thanks to the new tests, I was able to do a lot of fixes in the OpenAPI specs. ### Next steps - Explore the generation of resource clients using [openapi-python-client](https://github.com/openapi-generators/openapi-python-client). - Fully automate model updates based on changes in [apify-api/openapi](https://github.com/apify/apify-docs/tree/master/apify-api/openapi). - This will be released as part of the Apify client v3.0.
1 parent dc6cf5c commit 81ee194

107 files changed

Lines changed: 13963 additions & 5148 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/_tests.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
operating_systems: '["ubuntu-latest", "windows-latest"]'
2121
python_version_for_codecov: "3.14"
2222
operating_system_for_codecov: ubuntu-latest
23-
tests_concurrency: "1"
23+
tests_concurrency: "16"
2424

2525
integration_tests:
2626
name: Integration tests
@@ -36,4 +36,4 @@ jobs:
3636
operating_systems: '["ubuntu-latest"]'
3737
python_version_for_codecov: "3.14"
3838
operating_system_for_codecov: ubuntu-latest
39-
tests_concurrency: "1"
39+
tests_concurrency: "16"

datamodel_codegen_aliases.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"gitHubGistUrl": "github_gist_url"
3+
}

docs/01_overview/code/01_usage_async.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@ async def main() -> None:
1616
return
1717

1818
# Fetch results from the Actor run's default dataset.
19-
dataset_client = apify_client.dataset(call_result['defaultDatasetId'])
19+
dataset_client = apify_client.dataset(call_result.default_dataset_id)
2020
list_items_result = await dataset_client.list_items()
2121
print(f'Dataset: {list_items_result}')

docs/01_overview/code/01_usage_sync.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,6 @@ def main() -> None:
1616
return
1717

1818
# Fetch results from the Actor run's default dataset.
19-
dataset_client = apify_client.dataset(call_result['defaultDatasetId'])
19+
dataset_client = apify_client.dataset(call_result.default_dataset_id)
2020
list_items_result = dataset_client.list_items()
2121
print(f'Dataset: {list_items_result}')

docs/02_concepts/code/01_async_support.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ async def main() -> None:
1111

1212
# Start the Actor and get the run ID
1313
run_result = await actor_client.start()
14-
run_client = apify_client.run(run_result['id'])
14+
run_client = apify_client.run(run_result.id)
1515
log_client = run_client.log()
1616

1717
# Stream the logs
Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import timedelta
2+
13
from apify_client import ApifyClientAsync
24

35
TOKEN = 'MY-APIFY-TOKEN'
@@ -7,6 +9,6 @@ async def main() -> None:
79
apify_client = ApifyClientAsync(
810
token=TOKEN,
911
max_retries=8,
10-
min_delay_between_retries_millis=500, # 0.5s
11-
timeout_secs=360, # 6 mins
12+
min_delay_between_retries=timedelta(milliseconds=500),
13+
timeout=timedelta(seconds=360),
1214
)
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
1+
from datetime import timedelta
2+
13
from apify_client import ApifyClient
24

35
TOKEN = 'MY-APIFY-TOKEN'
46

57

6-
async def main() -> None:
8+
def main() -> None:
79
apify_client = ApifyClient(
810
token=TOKEN,
911
max_retries=8,
10-
min_delay_between_retries_millis=500, # 0.5s
11-
timeout_secs=360, # 6 mins
12+
min_delay_between_retries=timedelta(milliseconds=500),
13+
timeout=timedelta(seconds=360),
1214
)

docs/03_examples/code/01_input_async.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
from datetime import timedelta
23

34
from apify_client import ApifyClientAsync
45

@@ -16,7 +17,9 @@ async def main() -> None:
1617

1718
# Run the Actor and wait for it to finish up to 60 seconds.
1819
# Input is not persisted for next runs.
19-
run_result = await actor_client.call(run_input=input_data, timeout_secs=60)
20+
run_result = await actor_client.call(
21+
run_input=input_data, timeout=timedelta(seconds=60)
22+
)
2023

2124

2225
if __name__ == '__main__':

docs/03_examples/code/01_input_sync.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from datetime import timedelta
2+
13
from apify_client import ApifyClient
24

35
TOKEN = 'MY-APIFY-TOKEN'
@@ -14,7 +16,7 @@ def main() -> None:
1416

1517
# Run the Actor and wait for it to finish up to 60 seconds.
1618
# Input is not persisted for next runs.
17-
run_result = actor_client.call(run_input=input_data, timeout_secs=60)
19+
run_result = actor_client.call(run_input=input_data, timeout=timedelta(seconds=60))
1820

1921

2022
if __name__ == '__main__':

docs/03_examples/code/02_tasks_async.py

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,16 @@
11
import asyncio
22

33
from apify_client import ApifyClientAsync
4-
from apify_client.clients.resource_clients import TaskClientAsync
54

65
TOKEN = 'MY-APIFY-TOKEN'
76
HASHTAGS = ['zebra', 'lion', 'hippo']
87

98

10-
async def run_apify_task(client: TaskClientAsync) -> dict:
11-
result = await client.call()
12-
return result or {}
13-
14-
159
async def main() -> None:
1610
apify_client = ApifyClientAsync(token=TOKEN)
1711

1812
# Create Apify tasks
19-
apify_tasks = list[dict]()
13+
apify_tasks = []
2014
apify_tasks_client = apify_client.tasks()
2115

2216
for hashtag in HASHTAGS:
@@ -31,20 +25,19 @@ async def main() -> None:
3125
print('Tasks created:', apify_tasks)
3226

3327
# Create Apify task clients
34-
apify_task_clients = list[TaskClientAsync]()
35-
36-
for apify_task in apify_tasks:
37-
task_id = apify_task['id']
38-
apify_task_client = apify_client.task(task_id)
39-
apify_task_clients.append(apify_task_client)
28+
apify_task_clients = [apify_client.task(task.id) for task in apify_tasks]
4029

4130
print('Task clients created:', apify_task_clients)
4231

4332
# Execute Apify tasks
44-
run_apify_tasks = [run_apify_task(client) for client in apify_task_clients]
45-
task_run_results = await asyncio.gather(*run_apify_tasks)
33+
task_run_results = await asyncio.gather(
34+
*[client.call() for client in apify_task_clients]
35+
)
36+
37+
# Filter out None results (tasks that failed to return a run)
38+
successful_runs = [run for run in task_run_results if run is not None]
4639

47-
print('Task results:', task_run_results)
40+
print('Task results:', successful_runs)
4841

4942

5043
if __name__ == '__main__':

0 commit comments

Comments
 (0)