29
29
SDK_ROOT_PATH = Path (__file__ ).parent .parent .parent .resolve ()
30
30
31
31
32
- # To isolate the tests, we need to reset the used singletons before each test case
33
- # We also patch the default storage client with a tmp_path
34
32
@pytest .fixture (autouse = True )
35
33
def _reset_and_patch_default_instances () -> None :
34
+ """Reset the used singletons and patch the default storage client with a temporary directory.
35
+
36
+ To isolate the tests, we need to reset the used singletons before each test case. We also patch the default
37
+ storage client with a tmp_path.
38
+ """
36
39
from crawlee import service_container
37
40
38
41
cast (dict , service_container ._services ).clear ()
@@ -41,13 +44,14 @@ def _reset_and_patch_default_instances() -> None:
41
44
# TODO: StorageClientManager local storage client purge # noqa: TD003
42
45
43
46
44
- # This fixture can't be session-scoped,
45
- # because then you start getting `RuntimeError: Event loop is closed` errors,
46
- # because `httpx.AsyncClient` in `ApifyClientAsync` tries to reuse the same event loop across requests,
47
- # but `pytest-asyncio` closes the event loop after each test,
48
- # and uses a new one for the next test.
49
47
@pytest .fixture
50
48
def apify_client_async () -> ApifyClientAsync :
49
+ """Create an instance of the ApifyClientAsync.
50
+
51
+ This fixture can't be session-scoped, because then you start getting `RuntimeError: Event loop is closed` errors,
52
+ because `httpx.AsyncClient` in `ApifyClientAsync` tries to reuse the same event loop across requests,
53
+ but `pytest-asyncio` closes the event loop after each test, and uses a new one for the next test.
54
+ """
51
55
api_token = os .getenv (TOKEN_ENV_VAR )
52
56
api_url = os .getenv (API_URL_ENV_VAR )
53
57
@@ -57,65 +61,26 @@ def apify_client_async() -> ApifyClientAsync:
57
61
return ApifyClientAsync (api_token , api_url = api_url )
58
62
59
63
60
- class RunActorFunction (Protocol ):
61
- """A type for the `run_actor` fixture."""
62
-
63
- def __call__ (
64
- self ,
65
- actor : ActorClientAsync ,
66
- * ,
67
- run_input : Any = None ,
68
- ) -> Coroutine [None , None , ActorRun ]:
69
- """Initiate an Actor run and wait for its completion.
70
-
71
- Args:
72
- actor: Actor async client, in testing context usually created by `make_actor` fixture.
73
- run_input: Optional input for the Actor run.
74
-
75
- Returns:
76
- Actor run result.
77
- """
78
-
79
-
80
- @pytest .fixture
81
- async def run_actor (apify_client_async : ApifyClientAsync ) -> RunActorFunction :
82
- """Fixture for calling an Actor run and waiting for its completion.
83
-
84
- This fixture returns a function that initiates an Actor run with optional run input, waits for its completion,
85
- and retrieves the final result. It uses the `wait_for_finish` method with a timeout of 10 minutes.
86
-
87
- Returns:
88
- A coroutine that initiates an Actor run and waits for its completion.
89
- """
90
-
91
- async def _run_actor (actor : ActorClientAsync , * , run_input : Any = None ) -> ActorRun :
92
- call_result = await actor .call (run_input = run_input )
93
-
94
- assert isinstance (call_result , dict ), 'The result of ActorClientAsync.call() is not a dictionary.'
95
- assert 'id' in call_result , 'The result of ActorClientAsync.call() does not contain an ID.'
96
-
97
- run_client = apify_client_async .run (call_result ['id' ])
98
- run_result = await run_client .wait_for_finish (wait_secs = 600 )
99
-
100
- return ActorRun .model_validate (run_result )
101
-
102
- return _run_actor
103
-
104
-
105
- # Build the package wheel if it hasn't been built yet, and return the path to the wheel
106
64
@pytest .fixture (scope = 'session' )
107
65
def sdk_wheel_path (tmp_path_factory : pytest .TempPathFactory , testrun_uid : str ) -> Path :
66
+ """Build the package wheel if it hasn't been built yet, and return the path to the wheel."""
108
67
# Make sure the wheel is not being built concurrently across all the pytest-xdist runners,
109
- # through locking the building process with a temp file
68
+ # through locking the building process with a temp file.
110
69
with FileLock (tmp_path_factory .getbasetemp ().parent / 'sdk_wheel_build.lock' ):
111
70
# Make sure the wheel is built exactly once across across all the pytest-xdist runners,
112
- # through an indicator file saying that the wheel was already built
71
+ # through an indicator file saying that the wheel was already built.
113
72
was_wheel_built_this_test_run_file = tmp_path_factory .getbasetemp () / f'wheel_was_built_in_run_{ testrun_uid } '
114
73
if not was_wheel_built_this_test_run_file .exists ():
115
- subprocess .run ('python -m build' , cwd = SDK_ROOT_PATH , shell = True , check = True , capture_output = True ) # noqa: S602, S607
74
+ subprocess .run (
75
+ args = 'python -m build' ,
76
+ cwd = SDK_ROOT_PATH ,
77
+ shell = True ,
78
+ check = True ,
79
+ capture_output = True ,
80
+ )
116
81
was_wheel_built_this_test_run_file .touch ()
117
82
118
- # Read the current package version, necessary for getting the right wheel filename
83
+ # Read the current package version, necessary for getting the right wheel filename.
119
84
pyproject_toml_file = (SDK_ROOT_PATH / 'pyproject.toml' ).read_text (encoding = 'utf-8' )
120
85
for line in pyproject_toml_file .splitlines ():
121
86
if line .startswith ('version = ' ):
@@ -127,7 +92,7 @@ def sdk_wheel_path(tmp_path_factory: pytest.TempPathFactory, testrun_uid: str) -
127
92
128
93
wheel_path = SDK_ROOT_PATH / 'dist' / f'apify-{ sdk_version } -py3-none-any.whl'
129
94
130
- # Just to be sure
95
+ # Just to be sure.
131
96
assert wheel_path .exists ()
132
97
133
98
return wheel_path
@@ -173,56 +138,61 @@ def actor_base_source_files(sdk_wheel_path: Path) -> dict[str, str | bytes]:
173
138
return source_files
174
139
175
140
176
- # Just a type for the make_actor result, so that we can import it in tests
177
- class ActorFactory (Protocol ):
141
+ class MakeActorFunction (Protocol ):
142
+ """A type for the `make_actor` fixture."""
143
+
178
144
def __call__ (
179
145
self ,
180
- actor_label : str ,
146
+ label : str ,
181
147
* ,
182
148
main_func : Callable | None = None ,
183
149
main_py : str | None = None ,
184
150
source_files : Mapping [str , str | bytes ] | None = None ,
185
- ) -> Awaitable [ActorClientAsync ]: ...
186
-
187
-
188
- @pytest .fixture
189
- async def make_actor (
190
- actor_base_source_files : dict [str , str | bytes ],
191
- apify_client_async : ApifyClientAsync ,
192
- ) -> AsyncIterator [ActorFactory ]:
193
- """A fixture for returning a temporary Actor factory."""
194
- actor_clients_for_cleanup : list [ActorClientAsync ] = []
195
-
196
- async def _make_actor (
197
- actor_label : str ,
198
- * ,
199
- main_func : Callable | None = None ,
200
- main_py : str | None = None ,
201
- source_files : Mapping [str , str | bytes ] | None = None ,
202
- ) -> ActorClientAsync :
203
- """Create a temporary Actor from the given main function or source file(s).
151
+ ) -> Awaitable [ActorClientAsync ]:
152
+ """Create a temporary Actor from the given main function or source files.
204
153
205
154
The Actor will be uploaded to the Apify Platform, built there, and after the test finishes, it will
206
155
be automatically deleted.
207
156
208
157
You have to pass exactly one of the `main_func`, `main_py` and `source_files` arguments.
209
158
210
159
Args:
211
- actor_label : The label which will be a part of the generated Actor name
160
+ label : The label which will be a part of the generated Actor name.
212
161
main_func: The main function of the Actor.
213
162
main_py: The `src/main.py` file of the Actor.
214
163
source_files: A dictionary of the source files of the Actor.
215
164
216
165
Returns:
217
166
A resource client for the created Actor.
218
167
"""
168
+
169
+
170
+ @pytest .fixture
171
+ async def make_actor (
172
+ actor_base_source_files : dict [str , str | bytes ],
173
+ apify_client_async : ApifyClientAsync ,
174
+ ) -> AsyncIterator [MakeActorFunction ]:
175
+ """Fixture for creating temporary Actors for testing purposes.
176
+
177
+ This returns a function that creates a temporary Actor from the given main function or source files. The Actor
178
+ will be uploaded to the Apify Platform, built there, and after the test finishes, it will be automatically deleted.
179
+ """
180
+ actor_clients_for_cleanup : list [ActorClientAsync ] = []
181
+
182
+ async def _make_actor (
183
+ label : str ,
184
+ * ,
185
+ main_func : Callable | None = None ,
186
+ main_py : str | None = None ,
187
+ source_files : Mapping [str , str | bytes ] | None = None ,
188
+ ) -> ActorClientAsync :
219
189
if not (main_func or main_py or source_files ):
220
190
raise TypeError ('One of `main_func`, `main_py` or `source_files` arguments must be specified' )
221
191
222
192
if (main_func and main_py ) or (main_func and source_files ) or (main_py and source_files ):
223
193
raise TypeError ('Cannot specify more than one of `main_func`, `main_py` and `source_files` arguments' )
224
194
225
- actor_name = generate_unique_resource_name (actor_label )
195
+ actor_name = generate_unique_resource_name (label )
226
196
227
197
# Get the source of main_func and convert it into a reasonable main_py file.
228
198
if main_func :
@@ -250,7 +220,7 @@ async def _make_actor(
250
220
actor_source_files = actor_base_source_files .copy ()
251
221
actor_source_files .update (source_files )
252
222
253
- # Reformat the source files in a format that the Apify API understands
223
+ # Reformat the source files in a format that the Apify API understands.
254
224
source_files_for_api = []
255
225
for file_name , file_contents in actor_source_files .items ():
256
226
if isinstance (file_contents , str ):
@@ -295,15 +265,56 @@ async def _make_actor(
295
265
assert build_client_result is not None
296
266
assert build_client_result ['status' ] == ActorJobStatus .SUCCEEDED
297
267
298
- # We only mark the client for cleanup if the build succeeded,
299
- # so that if something goes wrong here,
300
- # you have a chance to check the error
268
+ # We only mark the client for cleanup if the build succeeded, so that if something goes wrong here,
269
+ # you have a chance to check the error.
301
270
actor_clients_for_cleanup .append (actor_client )
302
271
303
272
return actor_client
304
273
305
274
yield _make_actor
306
275
307
- # Delete all the generated actors
276
+ # Delete all the generated Actors.
308
277
for actor_client in actor_clients_for_cleanup :
309
278
await actor_client .delete ()
279
+
280
+
281
+ class RunActorFunction (Protocol ):
282
+ """A type for the `run_actor` fixture."""
283
+
284
+ def __call__ (
285
+ self ,
286
+ actor : ActorClientAsync ,
287
+ * ,
288
+ run_input : Any = None ,
289
+ ) -> Coroutine [None , None , ActorRun ]:
290
+ """Initiate an Actor run and wait for its completion.
291
+
292
+ Args:
293
+ actor: Actor async client, in testing context usually created by `make_actor` fixture.
294
+ run_input: Optional input for the Actor run.
295
+
296
+ Returns:
297
+ Actor run result.
298
+ """
299
+
300
+
301
+ @pytest .fixture
302
+ async def run_actor (apify_client_async : ApifyClientAsync ) -> RunActorFunction :
303
+ """Fixture for calling an Actor run and waiting for its completion.
304
+
305
+ This fixture returns a function that initiates an Actor run with optional run input, waits for its completion,
306
+ and retrieves the final result. It uses the `wait_for_finish` method with a timeout of 10 minutes.
307
+ """
308
+
309
+ async def _run_actor (actor : ActorClientAsync , * , run_input : Any = None ) -> ActorRun :
310
+ call_result = await actor .call (run_input = run_input )
311
+
312
+ assert isinstance (call_result , dict ), 'The result of ActorClientAsync.call() is not a dictionary.'
313
+ assert 'id' in call_result , 'The result of ActorClientAsync.call() does not contain an ID.'
314
+
315
+ run_client = apify_client_async .run (call_result ['id' ])
316
+ run_result = await run_client .wait_for_finish (wait_secs = 600 )
317
+
318
+ return ActorRun .model_validate (run_result )
319
+
320
+ return _run_actor
0 commit comments