Skip to content

Commit ed30461

Browse files
vdusekclaude
andauthored
refactor: Use ensure_context decorator in Actor class (#822)
## Summary - Replace manual `_raise_if_not_initialized()` calls with the `@ensure_context` decorator from Crawlee, matching the pattern already used in `ChargingManager` - Add `active` attribute to `_ActorType` (replaces `_is_initialized`) for compatibility with `ensure_context` - Remove the now-unused `_raise_if_not_initialized` method Reopens #400 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 26a880f commit ed30461

File tree

8 files changed

+103
-86
lines changed

8 files changed

+103
-86
lines changed

src/apify/_actor.py

Lines changed: 32 additions & 50 deletions
Large diffs are not rendered by default.

src/apify/_charging.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88

99
from pydantic import TypeAdapter
1010

11-
from crawlee._utils.context import ensure_context
12-
1311
from apify._models import (
1412
ActorRun,
1513
FlatPricePerMonthActorPricingInfo,
@@ -18,7 +16,7 @@
1816
PricePerDatasetItemActorPricingInfo,
1917
PricingModel,
2018
)
21-
from apify._utils import docs_group
19+
from apify._utils import docs_group, ensure_context
2220
from apify.log import logger
2321
from apify.storages import Dataset
2422

@@ -31,6 +29,8 @@
3129

3230
run_validator = TypeAdapter[ActorRun | None](ActorRun | None)
3331

32+
_ensure_context = ensure_context('active')
33+
3434

3535
@docs_group('Charging')
3636
class ChargingManager(Protocol):
@@ -201,7 +201,7 @@ async def __aexit__(
201201

202202
self.active = False
203203

204-
@ensure_context
204+
@_ensure_context
205205
async def charge(self, event_name: str, count: int = 1) -> ChargeResult:
206206
def calculate_chargeable() -> dict[str, int | None]:
207207
"""Calculate the maximum number of events of each type that can be charged within the current budget."""
@@ -291,14 +291,14 @@ def calculate_chargeable() -> dict[str, int | None]:
291291
chargeable_within_limit=calculate_chargeable(),
292292
)
293293

294-
@ensure_context
294+
@_ensure_context
295295
def calculate_total_charged_amount(self) -> Decimal:
296296
return sum(
297297
(item.total_charged_amount for item in self._charging_state.values()),
298298
start=Decimal(),
299299
)
300300

301-
@ensure_context
301+
@_ensure_context
302302
def calculate_max_event_charge_count_within_limit(self, event_name: str) -> int | None:
303303
pricing_info = self._pricing_info.get(event_name)
304304

@@ -315,7 +315,7 @@ def calculate_max_event_charge_count_within_limit(self, event_name: str) -> int
315315
result = (self._max_total_charge_usd - self.calculate_total_charged_amount()) / price
316316
return max(0, math.floor(result)) if result.is_finite() else None
317317

318-
@ensure_context
318+
@_ensure_context
319319
def get_pricing_info(self) -> ActorPricingInfo:
320320
return ActorPricingInfo(
321321
pricing_model=self._pricing_model,
@@ -328,12 +328,12 @@ def get_pricing_info(self) -> ActorPricingInfo:
328328
},
329329
)
330330

331-
@ensure_context
331+
@_ensure_context
332332
def get_charged_event_count(self, event_name: str) -> int:
333333
item = self._charging_state.get(event_name)
334334
return item.charge_count if item is not None else 0
335335

336-
@ensure_context
336+
@_ensure_context
337337
def get_max_total_charge_usd(self) -> Decimal:
338338
return self._max_total_charge_usd
339339

src/apify/_utils.py

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,48 @@
11
from __future__ import annotations
22

33
import builtins
4+
import inspect
45
import sys
6+
from collections.abc import Callable
57
from enum import Enum
8+
from functools import wraps
69
from importlib import metadata
7-
from typing import TYPE_CHECKING, Any, Literal
10+
from typing import Any, Literal, TypeVar, cast
811

9-
if TYPE_CHECKING:
10-
from collections.abc import Callable
12+
T = TypeVar('T', bound=Callable[..., Any])
13+
14+
15+
def ensure_context(attribute_name: str) -> Callable[[T], T]:
16+
"""Create a decorator that ensures the context manager is initialized before executing the method.
17+
18+
The decorator checks if the calling instance has the specified attribute and verifies that it is set to `True`.
19+
If the instance is inactive, it raises a `RuntimeError`. Works for both synchronous and asynchronous methods.
20+
21+
Args:
22+
attribute_name: The name of the boolean attribute to check on the instance.
23+
24+
Returns:
25+
A decorator that wraps methods with context checking.
26+
"""
27+
28+
def decorator(method: T) -> T:
29+
@wraps(method)
30+
def sync_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
31+
if not getattr(self, attribute_name, False):
32+
raise RuntimeError(f'The {self.__class__.__name__} is not active. Use it within the context.')
33+
34+
return method(self, *args, **kwargs)
35+
36+
@wraps(method)
37+
async def async_wrapper(self: Any, *args: Any, **kwargs: Any) -> Any:
38+
if not getattr(self, attribute_name, False):
39+
raise RuntimeError(f'The {self.__class__.__name__} is not active. Use it within the async context.')
40+
41+
return await method(self, *args, **kwargs)
42+
43+
return cast('T', async_wrapper if inspect.iscoroutinefunction(method) else sync_wrapper)
44+
45+
return decorator
1146

1247

1348
def get_system_info() -> dict:

tests/e2e/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ def _prepare_test_env() -> None:
6969
if hasattr(apify._actor.Actor, '__wrapped__'):
7070
delattr(apify._actor.Actor, '__wrapped__')
7171

72-
apify._actor.Actor._is_initialized = False
72+
apify._actor.Actor._active = False
7373

7474
# Set the environment variable for the local storage directory to the temporary path.
7575
monkeypatch.setenv(ApifyEnvVars.LOCAL_STORAGE_DIR, str(tmp_path))

tests/e2e/test_actor_lifecycle.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,24 +118,24 @@ async def test_actor_sequential_contexts(make_actor: MakeActorFunction, run_acto
118118
async def main() -> None:
119119
async with Actor as actor:
120120
actor._exit_process = False
121-
assert actor._is_initialized is True
121+
assert actor._active is True
122122

123123
# Actor after Actor.
124124
async with Actor as actor:
125125
actor._exit_process = False
126-
assert actor._is_initialized is True
126+
assert actor._active is True
127127

128128
# Actor() after Actor.
129129
async with Actor(exit_process=False) as actor:
130-
assert actor._is_initialized is True
130+
assert actor._active is True
131131

132132
# Actor() after Actor().
133133
async with Actor(exit_process=False) as actor:
134-
assert actor._is_initialized is True
134+
assert actor._active is True
135135

136136
# Actor after Actor().
137137
async with Actor as actor:
138-
assert actor._is_initialized is True
138+
assert actor._active is True
139139

140140
actor = await make_actor(label='actor-sequential-contexts', main_func=main)
141141
run_result = await run_actor(actor)

tests/integration/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ def _prepare_test_env() -> None:
6060
if hasattr(apify._actor.Actor, '__wrapped__'):
6161
delattr(apify._actor.Actor, '__wrapped__')
6262

63-
apify._actor.Actor._is_initialized = False
63+
apify._actor.Actor._active = False
6464

6565
# Set the environment variable for the local storage directory to the temporary path.
6666
monkeypatch.setenv(ApifyEnvVars.LOCAL_STORAGE_DIR, str(tmp_path))

tests/unit/actor/test_actor_lifecycle.py

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -68,41 +68,41 @@ async def test_actor_init_instance_manual() -> None:
6868
"""Test that Actor instance can be properly initialized and cleaned up manually."""
6969
actor = Actor()
7070
await actor.init()
71-
assert actor._is_initialized is True
71+
assert actor._active is True
7272
await actor.exit()
73-
assert actor._is_initialized is False
73+
assert actor._active is False
7474

7575

7676
async def test_actor_init_instance_async_with() -> None:
7777
"""Test that Actor instance can be properly initialized and cleaned up using async context manager."""
7878
actor = Actor()
7979
async with actor:
80-
assert actor._is_initialized is True
80+
assert actor._active is True
8181

82-
assert actor._is_initialized is False
82+
assert actor._active is False
8383

8484

8585
async def test_actor_init_class_manual() -> None:
8686
"""Test that Actor class can be properly initialized and cleaned up manually."""
8787
await Actor.init()
88-
assert Actor._is_initialized is True
88+
assert Actor._active is True
8989
await Actor.exit()
90-
assert not Actor._is_initialized
90+
assert not Actor._active
9191

9292

9393
async def test_actor_init_class_async_with() -> None:
9494
"""Test that Actor class can be properly initialized and cleaned up using async context manager."""
9595
async with Actor:
96-
assert Actor._is_initialized is True
96+
assert Actor._active is True
9797

98-
assert not Actor._is_initialized
98+
assert not Actor._active
9999

100100

101101
async def test_fail_properly_deinitializes_actor(actor: _ActorType) -> None:
102102
"""Test that fail() method properly deinitializes the Actor."""
103-
assert actor._is_initialized
103+
assert actor._active
104104
await actor.fail()
105-
assert actor._is_initialized is False
105+
assert actor._active is False
106106

107107

108108
async def test_actor_handles_exceptions_and_cleans_up_properly() -> None:
@@ -111,16 +111,16 @@ async def test_actor_handles_exceptions_and_cleans_up_properly() -> None:
111111

112112
with contextlib.suppress(Exception):
113113
async with Actor() as actor:
114-
assert actor._is_initialized
114+
assert actor._active
115115
raise Exception('Failed') # noqa: TRY002
116116

117117
assert actor is not None
118-
assert actor._is_initialized is False
118+
assert actor._active is False
119119

120120

121121
async def test_double_init_raises_runtime_error(actor: _ActorType) -> None:
122122
"""Test that attempting to initialize an already initialized Actor raises RuntimeError."""
123-
assert actor._is_initialized
123+
assert actor._active
124124
with pytest.raises(RuntimeError):
125125
await actor.init()
126126

@@ -196,7 +196,7 @@ def on_event(event_type: Event) -> Callable:
196196

197197
actor = Actor()
198198
async with actor:
199-
assert actor._is_initialized
199+
assert actor._active
200200
actor.on(Event.PERSIST_STATE, on_event(Event.PERSIST_STATE))
201201
actor.on(Event.SYSTEM_INFO, on_event(Event.SYSTEM_INFO))
202202
await asyncio.sleep(1)
@@ -249,12 +249,12 @@ async def test_actor_sequential_contexts(*, first_with_call: bool, second_with_c
249249
mock = AsyncMock()
250250
async with Actor(exit_process=False) if first_with_call else Actor as actor:
251251
await mock()
252-
assert actor._is_initialized is True
252+
assert actor._active is True
253253

254254
# After exiting the context, new Actor instance can be created without conflicts.
255255
async with Actor() if second_with_call else Actor as actor:
256256
await mock()
257-
assert actor._is_initialized is True
257+
assert actor._active is True
258258

259259
# The mock should have been called twice, once in each context.
260260
assert mock.call_count == 2

tests/unit/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ def prepare_test_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Callabl
6262
def _prepare_test_env() -> None:
6363
if hasattr(apify._actor.Actor, '__wrapped__'):
6464
delattr(apify._actor.Actor, '__wrapped__')
65-
apify._actor.Actor._is_initialized = False
65+
apify._actor.Actor._active = False
6666

6767
# Set the environment variable for the local storage directory to the temporary path.
6868
monkeypatch.setenv(ApifyEnvVars.LOCAL_STORAGE_DIR, str(tmp_path))

0 commit comments

Comments
 (0)