2929SDK_ROOT_PATH = Path (__file__ ).parent .parent .parent .resolve ()
3030
3131
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
3432@pytest .fixture (autouse = True )
3533def _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+ """
3639 from crawlee import service_container
3740
3841 cast (dict , service_container ._services ).clear ()
@@ -41,13 +44,14 @@ def _reset_and_patch_default_instances() -> None:
4144 # TODO: StorageClientManager local storage client purge # noqa: TD003
4245
4346
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.
4947@pytest .fixture
5048def 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+ """
5155 api_token = os .getenv (TOKEN_ENV_VAR )
5256 api_url = os .getenv (API_URL_ENV_VAR )
5357
@@ -57,65 +61,26 @@ def apify_client_async() -> ApifyClientAsync:
5761 return ApifyClientAsync (api_token , api_url = api_url )
5862
5963
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
10664@pytest .fixture (scope = 'session' )
10765def 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."""
10867 # 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.
11069 with FileLock (tmp_path_factory .getbasetemp ().parent / 'sdk_wheel_build.lock' ):
11170 # 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.
11372 was_wheel_built_this_test_run_file = tmp_path_factory .getbasetemp () / f'wheel_was_built_in_run_{ testrun_uid } '
11473 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+ )
11681 was_wheel_built_this_test_run_file .touch ()
11782
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.
11984 pyproject_toml_file = (SDK_ROOT_PATH / 'pyproject.toml' ).read_text (encoding = 'utf-8' )
12085 for line in pyproject_toml_file .splitlines ():
12186 if line .startswith ('version = ' ):
@@ -127,7 +92,7 @@ def sdk_wheel_path(tmp_path_factory: pytest.TempPathFactory, testrun_uid: str) -
12792
12893 wheel_path = SDK_ROOT_PATH / 'dist' / f'apify-{ sdk_version } -py3-none-any.whl'
12994
130- # Just to be sure
95+ # Just to be sure.
13196 assert wheel_path .exists ()
13297
13398 return wheel_path
@@ -173,56 +138,61 @@ def actor_base_source_files(sdk_wheel_path: Path) -> dict[str, str | bytes]:
173138 return source_files
174139
175140
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+
178144 def __call__ (
179145 self ,
180- actor_label : str ,
146+ label : str ,
181147 * ,
182148 main_func : Callable | None = None ,
183149 main_py : str | None = None ,
184150 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.
204153
205154 The Actor will be uploaded to the Apify Platform, built there, and after the test finishes, it will
206155 be automatically deleted.
207156
208157 You have to pass exactly one of the `main_func`, `main_py` and `source_files` arguments.
209158
210159 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.
212161 main_func: The main function of the Actor.
213162 main_py: The `src/main.py` file of the Actor.
214163 source_files: A dictionary of the source files of the Actor.
215164
216165 Returns:
217166 A resource client for the created Actor.
218167 """
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 :
219189 if not (main_func or main_py or source_files ):
220190 raise TypeError ('One of `main_func`, `main_py` or `source_files` arguments must be specified' )
221191
222192 if (main_func and main_py ) or (main_func and source_files ) or (main_py and source_files ):
223193 raise TypeError ('Cannot specify more than one of `main_func`, `main_py` and `source_files` arguments' )
224194
225- actor_name = generate_unique_resource_name (actor_label )
195+ actor_name = generate_unique_resource_name (label )
226196
227197 # Get the source of main_func and convert it into a reasonable main_py file.
228198 if main_func :
@@ -250,7 +220,7 @@ async def _make_actor(
250220 actor_source_files = actor_base_source_files .copy ()
251221 actor_source_files .update (source_files )
252222
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.
254224 source_files_for_api = []
255225 for file_name , file_contents in actor_source_files .items ():
256226 if isinstance (file_contents , str ):
@@ -295,15 +265,56 @@ async def _make_actor(
295265 assert build_client_result is not None
296266 assert build_client_result ['status' ] == ActorJobStatus .SUCCEEDED
297267
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.
301270 actor_clients_for_cleanup .append (actor_client )
302271
303272 return actor_client
304273
305274 yield _make_actor
306275
307- # Delete all the generated actors
276+ # Delete all the generated Actors.
308277 for actor_client in actor_clients_for_cleanup :
309278 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