diff --git a/.github/workflows/_tests.yaml b/.github/workflows/_tests.yaml index 3e562baf..1bf30fc9 100644 --- a/.github/workflows/_tests.yaml +++ b/.github/workflows/_tests.yaml @@ -28,4 +28,4 @@ jobs: operating_systems: '["ubuntu-latest"]' python_version_for_codecov: "3.14" operating_system_for_codecov: ubuntu-latest - tests_concurrency: "1" + tests_concurrency: "16" diff --git a/docs/01_overview/code/01_usage_async.py b/docs/01_overview/code/01_usage_async.py index 3ad4e883..4a45b1e2 100644 --- a/docs/01_overview/code/01_usage_async.py +++ b/docs/01_overview/code/01_usage_async.py @@ -16,6 +16,6 @@ async def main() -> None: return # Fetch results from the Actor run's default dataset. - dataset_client = apify_client.dataset(call_result['defaultDatasetId']) + dataset_client = apify_client.dataset(call_result.default_dataset_id) list_items_result = await dataset_client.list_items() print(f'Dataset: {list_items_result}') diff --git a/docs/01_overview/code/01_usage_sync.py b/docs/01_overview/code/01_usage_sync.py index afa15ffb..84e430fa 100644 --- a/docs/01_overview/code/01_usage_sync.py +++ b/docs/01_overview/code/01_usage_sync.py @@ -16,6 +16,6 @@ def main() -> None: return # Fetch results from the Actor run's default dataset. - dataset_client = apify_client.dataset(call_result['defaultDatasetId']) + dataset_client = apify_client.dataset(call_result.default_dataset_id) list_items_result = dataset_client.list_items() print(f'Dataset: {list_items_result}') diff --git a/docs/02_concepts/code/01_async_support.py b/docs/02_concepts/code/01_async_support.py index 22cc390e..e8fe81b0 100644 --- a/docs/02_concepts/code/01_async_support.py +++ b/docs/02_concepts/code/01_async_support.py @@ -11,7 +11,7 @@ async def main() -> None: # Start the Actor and get the run ID run_result = await actor_client.start() - run_client = apify_client.run(run_result['id']) + run_client = apify_client.run(run_result.id) log_client = run_client.log() # Stream the logs diff --git a/docs/03_examples/code/02_tasks_async.py b/docs/03_examples/code/02_tasks_async.py index d3e962fa..9894b20b 100644 --- a/docs/03_examples/code/02_tasks_async.py +++ b/docs/03_examples/code/02_tasks_async.py @@ -1,22 +1,22 @@ import asyncio from apify_client import ApifyClientAsync -from apify_client.clients.resource_clients import TaskClientAsync +from apify_client._models import Run, Task +from apify_client._resource_clients import TaskClientAsync TOKEN = 'MY-APIFY-TOKEN' HASHTAGS = ['zebra', 'lion', 'hippo'] -async def run_apify_task(client: TaskClientAsync) -> dict: - result = await client.call() - return result or {} +async def run_apify_task(client: TaskClientAsync) -> Run | None: + return await client.call() async def main() -> None: apify_client = ApifyClientAsync(token=TOKEN) # Create Apify tasks - apify_tasks = list[dict]() + apify_tasks = list[Task]() apify_tasks_client = apify_client.tasks() for hashtag in HASHTAGS: @@ -34,7 +34,7 @@ async def main() -> None: apify_task_clients = list[TaskClientAsync]() for apify_task in apify_tasks: - task_id = apify_task['id'] + task_id = apify_task.id apify_task_client = apify_client.task(task_id) apify_task_clients.append(apify_task_client) diff --git a/docs/03_examples/code/02_tasks_sync.py b/docs/03_examples/code/02_tasks_sync.py index 72437742..4e75d3dd 100644 --- a/docs/03_examples/code/02_tasks_sync.py +++ b/docs/03_examples/code/02_tasks_sync.py @@ -1,20 +1,20 @@ from apify_client import ApifyClient -from apify_client.clients.resource_clients import TaskClient +from apify_client._models import Run, Task +from apify_client._resource_clients import TaskClient TOKEN = 'MY-APIFY-TOKEN' HASHTAGS = ['zebra', 'lion', 'hippo'] -def run_apify_task(client: TaskClient) -> dict: - result = client.call() - return result or {} +def run_apify_task(client: TaskClient) -> Run | None: + return client.call() def main() -> None: apify_client = ApifyClient(token=TOKEN) # Create Apify tasks - apify_tasks = list[dict]() + apify_tasks = list[Task]() apify_tasks_client = apify_client.tasks() for hashtag in HASHTAGS: @@ -32,18 +32,19 @@ def main() -> None: apify_task_clients = list[TaskClient]() for apify_task in apify_tasks: - task_id = apify_task['id'] + task_id = apify_task.id apify_task_client = apify_client.task(task_id) apify_task_clients.append(apify_task_client) print('Task clients created:', apify_task_clients) # Execute Apify tasks - task_run_results = list[dict]() + task_run_results = list[Run]() for client in apify_task_clients: result = run_apify_task(client) - task_run_results.append(result) + if result is not None: + task_run_results.append(result) print('Task results:', task_run_results) diff --git a/docs/03_examples/code/03_retrieve_async.py b/docs/03_examples/code/03_retrieve_async.py index c6e35095..fc60d068 100644 --- a/docs/03_examples/code/03_retrieve_async.py +++ b/docs/03_examples/code/03_retrieve_async.py @@ -19,11 +19,11 @@ async def main() -> None: for dataset_item in actor_datasets.items: # Dataset items can be handled here. Dataset items can be paginated - dataset_client = apify_client.dataset(dataset_item['id']) + dataset_client = apify_client.dataset(dataset_item.id) dataset_items = await dataset_client.list_items(limit=1000) # Items can be pushed to single dataset - merging_dataset_client = apify_client.dataset(merging_dataset['id']) + merging_dataset_client = apify_client.dataset(merging_dataset.id) await merging_dataset_client.push_items(dataset_items.items) # ... diff --git a/docs/03_examples/code/03_retrieve_sync.py b/docs/03_examples/code/03_retrieve_sync.py index 7d92dd53..24e05e2f 100644 --- a/docs/03_examples/code/03_retrieve_sync.py +++ b/docs/03_examples/code/03_retrieve_sync.py @@ -17,11 +17,11 @@ def main() -> None: for dataset_item in actor_datasets.items: # Dataset items can be handled here. Dataset items can be paginated - dataset_client = apify_client.dataset(dataset_item['id']) + dataset_client = apify_client.dataset(dataset_item.id) dataset_items = dataset_client.list_items(limit=1000) # Items can be pushed to single dataset - merging_dataset_client = apify_client.dataset(merging_dataset['id']) + merging_dataset_client = apify_client.dataset(merging_dataset.id) merging_dataset_client.push_items(dataset_items.items) # ... diff --git a/pyproject.toml b/pyproject.toml index 32378f9e..1a9a37cd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,10 +25,10 @@ classifiers = [ ] keywords = ["apify", "api", "client", "automation", "crawling", "scraping"] dependencies = [ - "apify-shared>=2.1.0,<3.0.0", "colorama>=0.4.0", "impit>=0.9.2", "more_itertools>=10.0.0", + "pydantic[email]>=2.11.0", ] [project.urls] @@ -64,6 +64,7 @@ dev = [ "ty~=0.0.0", "types-colorama<0.5.0", "werkzeug<4.0.0", # Werkzeug is used by pytest-httpserver + "datamodel-code-generator[http,ruff]<1.0.0", ] [tool.hatch.build.targets.wheel] @@ -144,6 +145,12 @@ indent-style = "space" "N999", # Invalid module name "T201", # print found ] +"src/apify_client/_models.py" = [ + "D", # Everything from the pydocstyle + "E501", # Line too long + "ERA001", # Commented-out code + "TC003", # Move standard library import into a type-checking block +] [tool.ruff.lint.flake8-quotes] docstring-quotes = "double" @@ -171,10 +178,7 @@ python-version = "3.10" include = ["src", "tests", "scripts", "docs", "website"] [[tool.ty.overrides]] -include = [ - "docs/**/*.py", - "website/**/*.py", -] +include = ["docs/**/*.py", "website/**/*.py"] [tool.ty.overrides.rules] unresolved-import = "ignore" @@ -220,3 +224,22 @@ cwd = "website" [tool.poe.tasks.run-docs] shell = "./build_api_reference.sh && corepack enable && yarn && uv run yarn start" cwd = "website" + +# https://koxudaxi.github.io/datamodel-code-generator/ +[tool.datamodel-codegen] +# url = "https://docs.apify.com/api/openapi.json" +input = "../apify-docs/static/api/openapi.json" +input_file_type = "openapi" +output = "src/apify_client/_models.py" +target_python_version = "3.10" +output_model_type = "pydantic_v2.BaseModel" +use_schema_description = true +use_field_description = true +use_union_operator = true +capitalise_enum_members = true +collapse_root_models = true +set_default_enum_member = true +use_annotated = true +wrap_string_literal = true +snake_case_field = true +formatters = ["ruff-check", "ruff-format"] diff --git a/scripts/check_async_docstrings.py b/scripts/check_async_docstrings.py index 311a6a46..cf08a3a6 100755 --- a/scripts/check_async_docstrings.py +++ b/scripts/check_async_docstrings.py @@ -16,6 +16,10 @@ # Go through every Python file in that directory for client_source_path in clients_path.glob('**/*.py'): + # Skip _http_clients package - sync and async are in separate files there + if '_http_clients' in str(client_source_path): + continue + with open(client_source_path, encoding='utf-8') as source_file: # Read the source file and parse the code using Red Baron red = RedBaron(source_code=source_file.read()) @@ -27,6 +31,8 @@ # Find the corresponding sync classes (same name, but without -Async) sync_class = red.find('ClassNode', name=async_class.name.replace('ClientAsync', 'Client')) + if not sync_class: + continue # Go through all methods in the async class for async_method in async_class.find_all('DefNode'): diff --git a/scripts/fix_async_docstrings.py b/scripts/fix_async_docstrings.py index fd6ee04f..60634f0c 100755 --- a/scripts/fix_async_docstrings.py +++ b/scripts/fix_async_docstrings.py @@ -11,6 +11,10 @@ # Go through every Python file in that directory for client_source_path in clients_path.glob('**/*.py'): + # Skip _http_clients package - sync and async are in separate files there + if '_http_clients' in str(client_source_path): + continue + with open(client_source_path, 'r+', encoding='utf-8') as source_file: # Read the source file and parse the code using Red Baron red = RedBaron(source_code=source_file.read()) @@ -22,6 +26,8 @@ # Find the corresponding sync classes (same name, but without -Async) sync_class = red.find('ClassNode', name=async_class.name.replace('ClientAsync', 'Client')) + if not sync_class: + continue # Go through all methods in the async class for async_method in async_class.find_all('DefNode'): diff --git a/scripts/utils.py b/scripts/utils.py index 7eb07a8a..eac23e7a 100644 --- a/scripts/utils.py +++ b/scripts/utils.py @@ -25,7 +25,7 @@ def get_current_package_version() -> str: # It replaces the version number on the line with the format `version = "1.2.3"` def set_current_package_version(version: str) -> None: with open(PYPROJECT_TOML_FILE_PATH, 'r+', encoding='utf-8') as pyproject_toml_file: - updated_pyproject_toml_file_lines = [] + updated_pyproject_toml_file_lines = list[str]() version_string_found = False for line in pyproject_toml_file: line_processed = line @@ -59,8 +59,8 @@ def get_published_package_versions() -> list: package_data = json.load(urlopen(package_info_url)) # noqa: S310 published_versions = list(package_data['releases'].keys()) # If the URL returns 404, it means the package has no releases yet (which is okay in our case) - except HTTPError as e: - if e.code != 404: + except HTTPError as exc: + if exc.code != 404: raise published_versions = [] return published_versions diff --git a/src/apify_client/__init__.py b/src/apify_client/__init__.py index d8845f71..e2e6081b 100644 --- a/src/apify_client/__init__.py +++ b/src/apify_client/__init__.py @@ -1,6 +1,6 @@ from importlib import metadata -from .client import ApifyClient, ApifyClientAsync +from ._apify_client import ApifyClient, ApifyClientAsync __version__ = metadata.version('apify-client') diff --git a/src/apify_client/client.py b/src/apify_client/_apify_client.py similarity index 68% rename from src/apify_client/client.py rename to src/apify_client/_apify_client.py index b6ed7abf..8cb1c82c 100644 --- a/src/apify_client/client.py +++ b/src/apify_client/_apify_client.py @@ -1,12 +1,21 @@ from __future__ import annotations -from apify_client._http_client import HTTPClient, HTTPClientAsync -from apify_client._statistics import Statistics -from apify_client.clients import ( +from apify_client._client_registry import ClientRegistry, ClientRegistryAsync +from apify_client._config import ClientConfig +from apify_client._http_clients import HttpClient, HttpClientAsync +from apify_client._resource_clients import ( ActorClient, ActorClientAsync, ActorCollectionClient, ActorCollectionClientAsync, + ActorEnvVarClient, + ActorEnvVarClientAsync, + ActorEnvVarCollectionClient, + ActorEnvVarCollectionClientAsync, + ActorVersionClient, + ActorVersionClientAsync, + ActorVersionCollectionClient, + ActorVersionCollectionClientAsync, BuildClient, BuildClientAsync, BuildCollectionClient, @@ -50,60 +59,12 @@ WebhookDispatchCollectionClient, WebhookDispatchCollectionClientAsync, ) +from apify_client._statistics import ClientStatistics -DEFAULT_API_URL = 'https://api.apify.com' -DEFAULT_TIMEOUT = 360 -API_VERSION = 'v2' - -class _BaseApifyClient: - http_client: HTTPClient | HTTPClientAsync - - def __init__( - self, - token: str | None = None, - *, - api_url: str | None = None, - api_public_url: str | None = None, - max_retries: int | None = 8, - min_delay_between_retries_millis: int | None = 500, - timeout_secs: int | None = DEFAULT_TIMEOUT, - ) -> None: - """Initialize a new instance. - - Args: - token: The Apify API token. - api_url: The URL of the Apify API server to which to connect. Defaults to https://api.apify.com. It can - be an internal URL that is not globally accessible, in such case `api_public_url` should be set as well. - api_public_url: The globally accessible URL of the Apify API server. It should be set only if the `api_url` - is an internal URL that is not globally accessible. - max_retries: How many times to retry a failed request at most. - min_delay_between_retries_millis: How long will the client wait between retrying requests - (increases exponentially from this value). - timeout_secs: The socket timeout of the HTTP requests sent to the Apify API. - """ - self.token = token - api_url = (api_url or DEFAULT_API_URL).rstrip('/') - self.base_url = f'{api_url}/{API_VERSION}' - api_public_url = (api_public_url or DEFAULT_API_URL).rstrip('/') - self.public_base_url = f'{api_public_url}/{API_VERSION}' - self.max_retries = max_retries or 8 - self.min_delay_between_retries_millis = min_delay_between_retries_millis or 500 - self.timeout_secs = timeout_secs or DEFAULT_TIMEOUT - - def _options(self) -> dict: - return { - 'root_client': self, - 'base_url': self.base_url, - 'http_client': self.http_client, - } - - -class ApifyClient(_BaseApifyClient): +class ApifyClient: """The Apify API client.""" - http_client: HTTPClient - def __init__( self, token: str | None = None, @@ -112,7 +73,7 @@ def __init__( api_public_url: str | None = None, max_retries: int | None = 8, min_delay_between_retries_millis: int | None = 500, - timeout_secs: int | None = DEFAULT_TIMEOUT, + timeout_secs: int | None = 360, ) -> None: """Initialize a new instance. @@ -127,35 +88,57 @@ def __init__( (increases exponentially from this value). timeout_secs: The socket timeout of the HTTP requests sent to the Apify API. """ - super().__init__( - token, + self._config = ClientConfig.from_user_params( + token=token, api_url=api_url, api_public_url=api_public_url, max_retries=max_retries, min_delay_between_retries_millis=min_delay_between_retries_millis, timeout_secs=timeout_secs, ) - - self.stats = Statistics() - self.http_client = HTTPClient( - token=token, - max_retries=self.max_retries, - min_delay_between_retries_millis=self.min_delay_between_retries_millis, - timeout_secs=self.timeout_secs, - stats=self.stats, + self._statistics = ClientStatistics() + self._http_client = HttpClient(config=self._config, statistics=self._statistics) + + # Create client classes config for dependency injection + self._client_registry = ClientRegistry( + actor_client=ActorClient, + actor_version_client=ActorVersionClient, + actor_version_collection_client=ActorVersionCollectionClient, + actor_env_var_client=ActorEnvVarClient, + actor_env_var_collection_client=ActorEnvVarCollectionClient, + build_client=BuildClient, + build_collection_client=BuildCollectionClient, + run_client=RunClient, + run_collection_client=RunCollectionClient, + dataset_client=DatasetClient, + key_value_store_client=KeyValueStoreClient, + request_queue_client=RequestQueueClient, + webhook_client=WebhookClient, + webhook_collection_client=WebhookCollectionClient, + webhook_dispatch_collection_client=WebhookDispatchCollectionClient, + log_client=LogClient, ) + @property + def _base_kwargs(self) -> dict: + return { + 'base_url': self._config.base_url, + 'public_base_url': self._config.public_base_url, + 'http_client': self._http_client, + 'client_registry': self._client_registry, + } + def actor(self, actor_id: str) -> ActorClient: """Retrieve the sub-client for manipulating a single Actor. Args: actor_id: ID of the Actor to be manipulated. """ - return ActorClient(resource_id=actor_id, **self._options()) + return ActorClient(resource_id=actor_id, **self._base_kwargs) def actors(self) -> ActorCollectionClient: """Retrieve the sub-client for manipulating Actors.""" - return ActorCollectionClient(**self._options()) + return ActorCollectionClient(**self._base_kwargs) def build(self, build_id: str) -> BuildClient: """Retrieve the sub-client for manipulating a single Actor build. @@ -163,11 +146,11 @@ def build(self, build_id: str) -> BuildClient: Args: build_id: ID of the Actor build to be manipulated. """ - return BuildClient(resource_id=build_id, **self._options()) + return BuildClient(resource_id=build_id, **self._base_kwargs) def builds(self) -> BuildCollectionClient: """Retrieve the sub-client for querying multiple builds of a user.""" - return BuildCollectionClient(**self._options()) + return BuildCollectionClient(**self._base_kwargs) def run(self, run_id: str) -> RunClient: """Retrieve the sub-client for manipulating a single Actor run. @@ -175,11 +158,11 @@ def run(self, run_id: str) -> RunClient: Args: run_id: ID of the Actor run to be manipulated. """ - return RunClient(resource_id=run_id, **self._options()) + return RunClient(resource_id=run_id, **self._base_kwargs) def runs(self) -> RunCollectionClient: """Retrieve the sub-client for querying multiple Actor runs of a user.""" - return RunCollectionClient(**self._options()) + return RunCollectionClient(**self._base_kwargs) def dataset(self, dataset_id: str) -> DatasetClient: """Retrieve the sub-client for manipulating a single dataset. @@ -187,11 +170,11 @@ def dataset(self, dataset_id: str) -> DatasetClient: Args: dataset_id: ID of the dataset to be manipulated. """ - return DatasetClient(resource_id=dataset_id, **self._options()) + return DatasetClient(resource_id=dataset_id, **self._base_kwargs) def datasets(self) -> DatasetCollectionClient: """Retrieve the sub-client for manipulating datasets.""" - return DatasetCollectionClient(**self._options()) + return DatasetCollectionClient(**self._base_kwargs) def key_value_store(self, key_value_store_id: str) -> KeyValueStoreClient: """Retrieve the sub-client for manipulating a single key-value store. @@ -199,11 +182,11 @@ def key_value_store(self, key_value_store_id: str) -> KeyValueStoreClient: Args: key_value_store_id: ID of the key-value store to be manipulated. """ - return KeyValueStoreClient(resource_id=key_value_store_id, **self._options()) + return KeyValueStoreClient(resource_id=key_value_store_id, **self._base_kwargs) def key_value_stores(self) -> KeyValueStoreCollectionClient: """Retrieve the sub-client for manipulating key-value stores.""" - return KeyValueStoreCollectionClient(**self._options()) + return KeyValueStoreCollectionClient(**self._base_kwargs) def request_queue(self, request_queue_id: str, *, client_key: str | None = None) -> RequestQueueClient: """Retrieve the sub-client for manipulating a single request queue. @@ -212,11 +195,11 @@ def request_queue(self, request_queue_id: str, *, client_key: str | None = None) request_queue_id: ID of the request queue to be manipulated. client_key: A unique identifier of the client accessing the request queue. """ - return RequestQueueClient(resource_id=request_queue_id, client_key=client_key, **self._options()) + return RequestQueueClient(resource_id=request_queue_id, client_key=client_key, **self._base_kwargs) def request_queues(self) -> RequestQueueCollectionClient: """Retrieve the sub-client for manipulating request queues.""" - return RequestQueueCollectionClient(**self._options()) + return RequestQueueCollectionClient(**self._base_kwargs) def webhook(self, webhook_id: str) -> WebhookClient: """Retrieve the sub-client for manipulating a single webhook. @@ -224,11 +207,11 @@ def webhook(self, webhook_id: str) -> WebhookClient: Args: webhook_id: ID of the webhook to be manipulated. """ - return WebhookClient(resource_id=webhook_id, **self._options()) + return WebhookClient(resource_id=webhook_id, **self._base_kwargs) def webhooks(self) -> WebhookCollectionClient: """Retrieve the sub-client for querying multiple webhooks of a user.""" - return WebhookCollectionClient(**self._options()) + return WebhookCollectionClient(**self._base_kwargs) def webhook_dispatch(self, webhook_dispatch_id: str) -> WebhookDispatchClient: """Retrieve the sub-client for accessing a single webhook dispatch. @@ -236,11 +219,11 @@ def webhook_dispatch(self, webhook_dispatch_id: str) -> WebhookDispatchClient: Args: webhook_dispatch_id: ID of the webhook dispatch to access. """ - return WebhookDispatchClient(resource_id=webhook_dispatch_id, **self._options()) + return WebhookDispatchClient(resource_id=webhook_dispatch_id, **self._base_kwargs) def webhook_dispatches(self) -> WebhookDispatchCollectionClient: """Retrieve the sub-client for querying multiple webhook dispatches of a user.""" - return WebhookDispatchCollectionClient(**self._options()) + return WebhookDispatchCollectionClient(**self._base_kwargs) def schedule(self, schedule_id: str) -> ScheduleClient: """Retrieve the sub-client for manipulating a single schedule. @@ -248,11 +231,11 @@ def schedule(self, schedule_id: str) -> ScheduleClient: Args: schedule_id: ID of the schedule to be manipulated. """ - return ScheduleClient(resource_id=schedule_id, **self._options()) + return ScheduleClient(resource_id=schedule_id, **self._base_kwargs) def schedules(self) -> ScheduleCollectionClient: """Retrieve the sub-client for manipulating schedules.""" - return ScheduleCollectionClient(**self._options()) + return ScheduleCollectionClient(**self._base_kwargs) def log(self, build_or_run_id: str) -> LogClient: """Retrieve the sub-client for retrieving logs. @@ -260,7 +243,7 @@ def log(self, build_or_run_id: str) -> LogClient: Args: build_or_run_id: ID of the Actor build or run for which to access the log. """ - return LogClient(resource_id=build_or_run_id, **self._options()) + return LogClient(resource_id=build_or_run_id, **self._base_kwargs) def task(self, task_id: str) -> TaskClient: """Retrieve the sub-client for manipulating a single task. @@ -268,11 +251,11 @@ def task(self, task_id: str) -> TaskClient: Args: task_id: ID of the task to be manipulated. """ - return TaskClient(resource_id=task_id, **self._options()) + return TaskClient(resource_id=task_id, **self._base_kwargs) def tasks(self) -> TaskCollectionClient: """Retrieve the sub-client for manipulating tasks.""" - return TaskCollectionClient(**self._options()) + return TaskCollectionClient(**self._base_kwargs) def user(self, user_id: str | None = None) -> UserClient: """Retrieve the sub-client for querying users. @@ -280,18 +263,16 @@ def user(self, user_id: str | None = None) -> UserClient: Args: user_id: ID of user to be queried. If None, queries the user belonging to the token supplied to the client. """ - return UserClient(resource_id=user_id, **self._options()) + return UserClient(resource_id=user_id, **self._base_kwargs) def store(self) -> StoreCollectionClient: """Retrieve the sub-client for Apify store.""" - return StoreCollectionClient(**self._options()) + return StoreCollectionClient(**self._base_kwargs) -class ApifyClientAsync(_BaseApifyClient): +class ApifyClientAsync: """The asynchronous version of the Apify API client.""" - http_client: HTTPClientAsync - def __init__( self, token: str | None = None, @@ -300,7 +281,7 @@ def __init__( api_public_url: str | None = None, max_retries: int | None = 8, min_delay_between_retries_millis: int | None = 500, - timeout_secs: int | None = DEFAULT_TIMEOUT, + timeout_secs: int | None = 360, ) -> None: """Initialize a new instance. @@ -315,35 +296,57 @@ def __init__( (increases exponentially from this value). timeout_secs: The socket timeout of the HTTP requests sent to the Apify API. """ - super().__init__( - token, + self._config = ClientConfig.from_user_params( + token=token, api_url=api_url, api_public_url=api_public_url, max_retries=max_retries, min_delay_between_retries_millis=min_delay_between_retries_millis, timeout_secs=timeout_secs, ) - - self.stats = Statistics() - self.http_client = HTTPClientAsync( - token=token, - max_retries=self.max_retries, - min_delay_between_retries_millis=self.min_delay_between_retries_millis, - timeout_secs=self.timeout_secs, - stats=self.stats, + self._statistics = ClientStatistics() + self._http_client = HttpClientAsync(config=self._config, statistics=self._statistics) + + # Create async client classes config for dependency injection + self._client_registry = ClientRegistryAsync( + actor_client=ActorClientAsync, + actor_version_client=ActorVersionClientAsync, + actor_version_collection_client=ActorVersionCollectionClientAsync, + actor_env_var_client=ActorEnvVarClientAsync, + actor_env_var_collection_client=ActorEnvVarCollectionClientAsync, + build_client=BuildClientAsync, + build_collection_client=BuildCollectionClientAsync, + run_client=RunClientAsync, + run_collection_client=RunCollectionClientAsync, + dataset_client=DatasetClientAsync, + key_value_store_client=KeyValueStoreClientAsync, + request_queue_client=RequestQueueClientAsync, + webhook_client=WebhookClientAsync, + webhook_collection_client=WebhookCollectionClientAsync, + webhook_dispatch_collection_client=WebhookDispatchCollectionClientAsync, + log_client=LogClientAsync, ) + @property + def _base_kwargs(self) -> dict: + return { + 'base_url': self._config.base_url, + 'public_base_url': self._config.public_base_url, + 'http_client': self._http_client, + 'client_registry': self._client_registry, + } + def actor(self, actor_id: str) -> ActorClientAsync: """Retrieve the sub-client for manipulating a single Actor. Args: actor_id: ID of the Actor to be manipulated. """ - return ActorClientAsync(resource_id=actor_id, **self._options()) + return ActorClientAsync(resource_id=actor_id, **self._base_kwargs) def actors(self) -> ActorCollectionClientAsync: """Retrieve the sub-client for manipulating Actors.""" - return ActorCollectionClientAsync(**self._options()) + return ActorCollectionClientAsync(**self._base_kwargs) def build(self, build_id: str) -> BuildClientAsync: """Retrieve the sub-client for manipulating a single Actor build. @@ -351,11 +354,11 @@ def build(self, build_id: str) -> BuildClientAsync: Args: build_id: ID of the Actor build to be manipulated. """ - return BuildClientAsync(resource_id=build_id, **self._options()) + return BuildClientAsync(resource_id=build_id, **self._base_kwargs) def builds(self) -> BuildCollectionClientAsync: """Retrieve the sub-client for querying multiple builds of a user.""" - return BuildCollectionClientAsync(**self._options()) + return BuildCollectionClientAsync(**self._base_kwargs) def run(self, run_id: str) -> RunClientAsync: """Retrieve the sub-client for manipulating a single Actor run. @@ -363,11 +366,11 @@ def run(self, run_id: str) -> RunClientAsync: Args: run_id: ID of the Actor run to be manipulated. """ - return RunClientAsync(resource_id=run_id, **self._options()) + return RunClientAsync(resource_id=run_id, **self._base_kwargs) def runs(self) -> RunCollectionClientAsync: """Retrieve the sub-client for querying multiple Actor runs of a user.""" - return RunCollectionClientAsync(**self._options()) + return RunCollectionClientAsync(**self._base_kwargs) def dataset(self, dataset_id: str) -> DatasetClientAsync: """Retrieve the sub-client for manipulating a single dataset. @@ -375,11 +378,11 @@ def dataset(self, dataset_id: str) -> DatasetClientAsync: Args: dataset_id: ID of the dataset to be manipulated. """ - return DatasetClientAsync(resource_id=dataset_id, **self._options()) + return DatasetClientAsync(resource_id=dataset_id, **self._base_kwargs) def datasets(self) -> DatasetCollectionClientAsync: """Retrieve the sub-client for manipulating datasets.""" - return DatasetCollectionClientAsync(**self._options()) + return DatasetCollectionClientAsync(**self._base_kwargs) def key_value_store(self, key_value_store_id: str) -> KeyValueStoreClientAsync: """Retrieve the sub-client for manipulating a single key-value store. @@ -387,11 +390,11 @@ def key_value_store(self, key_value_store_id: str) -> KeyValueStoreClientAsync: Args: key_value_store_id: ID of the key-value store to be manipulated. """ - return KeyValueStoreClientAsync(resource_id=key_value_store_id, **self._options()) + return KeyValueStoreClientAsync(resource_id=key_value_store_id, **self._base_kwargs) def key_value_stores(self) -> KeyValueStoreCollectionClientAsync: """Retrieve the sub-client for manipulating key-value stores.""" - return KeyValueStoreCollectionClientAsync(**self._options()) + return KeyValueStoreCollectionClientAsync(**self._base_kwargs) def request_queue(self, request_queue_id: str, *, client_key: str | None = None) -> RequestQueueClientAsync: """Retrieve the sub-client for manipulating a single request queue. @@ -400,11 +403,11 @@ def request_queue(self, request_queue_id: str, *, client_key: str | None = None) request_queue_id: ID of the request queue to be manipulated. client_key: A unique identifier of the client accessing the request queue. """ - return RequestQueueClientAsync(resource_id=request_queue_id, client_key=client_key, **self._options()) + return RequestQueueClientAsync(resource_id=request_queue_id, client_key=client_key, **self._base_kwargs) def request_queues(self) -> RequestQueueCollectionClientAsync: """Retrieve the sub-client for manipulating request queues.""" - return RequestQueueCollectionClientAsync(**self._options()) + return RequestQueueCollectionClientAsync(**self._base_kwargs) def webhook(self, webhook_id: str) -> WebhookClientAsync: """Retrieve the sub-client for manipulating a single webhook. @@ -412,11 +415,11 @@ def webhook(self, webhook_id: str) -> WebhookClientAsync: Args: webhook_id: ID of the webhook to be manipulated. """ - return WebhookClientAsync(resource_id=webhook_id, **self._options()) + return WebhookClientAsync(resource_id=webhook_id, **self._base_kwargs) def webhooks(self) -> WebhookCollectionClientAsync: """Retrieve the sub-client for querying multiple webhooks of a user.""" - return WebhookCollectionClientAsync(**self._options()) + return WebhookCollectionClientAsync(**self._base_kwargs) def webhook_dispatch(self, webhook_dispatch_id: str) -> WebhookDispatchClientAsync: """Retrieve the sub-client for accessing a single webhook dispatch. @@ -424,11 +427,11 @@ def webhook_dispatch(self, webhook_dispatch_id: str) -> WebhookDispatchClientAsy Args: webhook_dispatch_id: ID of the webhook dispatch to access. """ - return WebhookDispatchClientAsync(resource_id=webhook_dispatch_id, **self._options()) + return WebhookDispatchClientAsync(resource_id=webhook_dispatch_id, **self._base_kwargs) def webhook_dispatches(self) -> WebhookDispatchCollectionClientAsync: """Retrieve the sub-client for querying multiple webhook dispatches of a user.""" - return WebhookDispatchCollectionClientAsync(**self._options()) + return WebhookDispatchCollectionClientAsync(**self._base_kwargs) def schedule(self, schedule_id: str) -> ScheduleClientAsync: """Retrieve the sub-client for manipulating a single schedule. @@ -436,11 +439,11 @@ def schedule(self, schedule_id: str) -> ScheduleClientAsync: Args: schedule_id: ID of the schedule to be manipulated. """ - return ScheduleClientAsync(resource_id=schedule_id, **self._options()) + return ScheduleClientAsync(resource_id=schedule_id, **self._base_kwargs) def schedules(self) -> ScheduleCollectionClientAsync: """Retrieve the sub-client for manipulating schedules.""" - return ScheduleCollectionClientAsync(**self._options()) + return ScheduleCollectionClientAsync(**self._base_kwargs) def log(self, build_or_run_id: str) -> LogClientAsync: """Retrieve the sub-client for retrieving logs. @@ -448,7 +451,7 @@ def log(self, build_or_run_id: str) -> LogClientAsync: Args: build_or_run_id: ID of the Actor build or run for which to access the log. """ - return LogClientAsync(resource_id=build_or_run_id, **self._options()) + return LogClientAsync(resource_id=build_or_run_id, **self._base_kwargs) def task(self, task_id: str) -> TaskClientAsync: """Retrieve the sub-client for manipulating a single task. @@ -456,11 +459,11 @@ def task(self, task_id: str) -> TaskClientAsync: Args: task_id: ID of the task to be manipulated. """ - return TaskClientAsync(resource_id=task_id, **self._options()) + return TaskClientAsync(resource_id=task_id, **self._base_kwargs) def tasks(self) -> TaskCollectionClientAsync: """Retrieve the sub-client for manipulating tasks.""" - return TaskCollectionClientAsync(**self._options()) + return TaskCollectionClientAsync(**self._base_kwargs) def user(self, user_id: str | None = None) -> UserClientAsync: """Retrieve the sub-client for querying users. @@ -468,8 +471,8 @@ def user(self, user_id: str | None = None) -> UserClientAsync: Args: user_id: ID of user to be queried. If None, queries the user belonging to the token supplied to the client. """ - return UserClientAsync(resource_id=user_id, **self._options()) + return UserClientAsync(resource_id=user_id, **self._base_kwargs) def store(self) -> StoreCollectionClientAsync: """Retrieve the sub-client for Apify store.""" - return StoreCollectionClientAsync(**self._options()) + return StoreCollectionClientAsync(**self._base_kwargs) diff --git a/src/apify_client/_client_registry.py b/src/apify_client/_client_registry.py new file mode 100644 index 00000000..8ede872d --- /dev/null +++ b/src/apify_client/_client_registry.py @@ -0,0 +1,117 @@ +"""Client classes configuration for dependency injection.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apify_client._resource_clients import ( + ActorClient, + ActorClientAsync, + ActorEnvVarClient, + ActorEnvVarClientAsync, + ActorEnvVarCollectionClient, + ActorEnvVarCollectionClientAsync, + ActorVersionClient, + ActorVersionClientAsync, + ActorVersionCollectionClient, + ActorVersionCollectionClientAsync, + BuildClient, + BuildClientAsync, + BuildCollectionClient, + BuildCollectionClientAsync, + DatasetClient, + DatasetClientAsync, + KeyValueStoreClient, + KeyValueStoreClientAsync, + LogClient, + LogClientAsync, + RequestQueueClient, + RequestQueueClientAsync, + RunClient, + RunClientAsync, + RunCollectionClient, + RunCollectionClientAsync, + WebhookClient, + WebhookClientAsync, + WebhookCollectionClient, + WebhookCollectionClientAsync, + WebhookDispatchCollectionClient, + WebhookDispatchCollectionClientAsync, + ) + + +@dataclass +class ClientRegistry: + """Bundle of all sync client classes for dependency injection. + + This config object is passed through the client hierarchy to avoid + parameter explosion when clients need to create other clients. + Each resource client receives this config and can instantiate other + clients as needed without knowing about transitive dependencies. + """ + + # Actor-related + actor_client: type[ActorClient] + actor_version_client: type[ActorVersionClient] + actor_version_collection_client: type[ActorVersionCollectionClient] + actor_env_var_client: type[ActorEnvVarClient] + actor_env_var_collection_client: type[ActorEnvVarCollectionClient] + + # Build-related + build_client: type[BuildClient] + build_collection_client: type[BuildCollectionClient] + + # Run-related + run_client: type[RunClient] + run_collection_client: type[RunCollectionClient] + + # Storage-related + dataset_client: type[DatasetClient] + key_value_store_client: type[KeyValueStoreClient] + request_queue_client: type[RequestQueueClient] + + # Webhook-related + webhook_client: type[WebhookClient] + webhook_collection_client: type[WebhookCollectionClient] + webhook_dispatch_collection_client: type[WebhookDispatchCollectionClient] + + # Utilities + log_client: type[LogClient] + + +@dataclass +class ClientRegistryAsync: + """Bundle of all async client classes for dependency injection. + + Async version of ClientRegistry for use with ApifyClientAsync. + """ + + # Actor-related + actor_client: type[ActorClientAsync] + actor_version_client: type[ActorVersionClientAsync] + actor_version_collection_client: type[ActorVersionCollectionClientAsync] + actor_env_var_client: type[ActorEnvVarClientAsync] + actor_env_var_collection_client: type[ActorEnvVarCollectionClientAsync] + + # Build-related + build_client: type[BuildClientAsync] + build_collection_client: type[BuildCollectionClientAsync] + + # Run-related + run_client: type[RunClientAsync] + run_collection_client: type[RunCollectionClientAsync] + + # Storage-related + dataset_client: type[DatasetClientAsync] + key_value_store_client: type[KeyValueStoreClientAsync] + request_queue_client: type[RequestQueueClientAsync] + + # Webhook-related + webhook_client: type[WebhookClientAsync] + webhook_collection_client: type[WebhookCollectionClientAsync] + webhook_dispatch_collection_client: type[WebhookDispatchCollectionClientAsync] + + # Utilities + log_client: type[LogClientAsync] diff --git a/src/apify_client/_config.py b/src/apify_client/_config.py new file mode 100644 index 00000000..13dbc24f --- /dev/null +++ b/src/apify_client/_config.py @@ -0,0 +1,83 @@ +"""Client configuration module. + +This module provides the ClientConfig dataclass that encapsulates all +configuration options for the Apify API client. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +DEFAULT_API_URL = 'https://api.apify.com' +DEFAULT_TIMEOUT = 360 +API_VERSION = 'v2' + + +@dataclass(frozen=True) +class ClientConfig: + """Immutable configuration for Apify HTTP client. + + This dataclass holds all configuration options needed by the HTTP client + to communicate with the Apify API. It is created once by ApifyClient + and shared across all resource clients. + + All fields are frozen (immutable) to prevent accidental modification + after initialization. + """ + + base_url: str + """Base URL of the Apify API (e.g., 'https://api.apify.com/v2').""" + + public_base_url: str + """Public base URL for CDN access (e.g., 'https://cdn.apify.com/v2').""" + + token: str | None = None + """Apify API token for authentication.""" + + max_retries: int = 8 + """Maximum number of retries for failed requests.""" + + min_delay_between_retries_millis: int = 500 + """Minimum delay between retries in milliseconds (increases exponentially).""" + + timeout_secs: int = 360 + """Request timeout in seconds.""" + + @classmethod + def from_user_params( + cls, + *, + token: str | None = None, + api_url: str | None = None, + api_public_url: str | None = None, + max_retries: int | None = 8, + min_delay_between_retries_millis: int | None = 500, + timeout_secs: int | None = 360, + ) -> ClientConfig: + """Create ClientConfig from user-provided parameters. + + This factory method processes user input and creates a properly + formatted ClientConfig instance with sensible defaults. + + Args: + token: Apify API token for authentication. + api_url: Base API URL (default: https://api.apify.com). + api_public_url: Public CDN URL (default: same as api_url). + max_retries: Maximum number of retries for failed requests (default: 8). + min_delay_between_retries_millis: Minimum delay between retries in ms (default: 500). + timeout_secs: Request timeout in seconds (default: 360). + + Returns: + Immutable ClientConfig instance. + """ + api_url = (api_url or DEFAULT_API_URL).rstrip('/') + api_public_url = (api_public_url or DEFAULT_API_URL).rstrip('/') + + return cls( + base_url=f'{api_url}/{API_VERSION}', + public_base_url=f'{api_public_url}/{API_VERSION}', + token=token, + max_retries=max_retries or 8, + min_delay_between_retries_millis=min_delay_between_retries_millis or 500, + timeout_secs=timeout_secs or DEFAULT_TIMEOUT, + ) diff --git a/src/apify_client/_consts.py b/src/apify_client/_consts.py new file mode 100644 index 00000000..4ee7601e --- /dev/null +++ b/src/apify_client/_consts.py @@ -0,0 +1,145 @@ +"""Constants and enums used by the Apify client.""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + +# Type aliases +JsonSerializable = str | int | float | bool | None | dict[str, Any] | list[Any] +"""Type for representing json-serializable values. It's close enough to the real thing supported by json.parse. +It was suggested in a discussion with (and approved by) Guido van Rossum, so I'd consider it correct enough. +""" + +# Constants for wait_for_finish functionality +DEFAULT_WAIT_FOR_FINISH_SEC = 999999 +"""Default maximum wait time for job completion (effectively infinite).""" + +DEFAULT_WAIT_WHEN_JOB_NOT_EXIST_SEC = 3 +"""How long to wait for a job to exist before giving up.""" + +# Standard timeout values for API operations +FAST_OPERATION_TIMEOUT_SECS = 5 +"""Timeout for fast, idempotent operations (e.g., GET, DELETE).""" + +STANDARD_OPERATION_TIMEOUT_SECS = 30 +"""Timeout for operations that may take longer (e.g., list operations, batch operations).""" + + +class ActorJobStatus(str, Enum): + """Available statuses for Actor jobs (runs or builds). + + These statuses represent the lifecycle of an Actor execution, + from initialization to completion or termination. + """ + + READY = 'READY' + """Actor job has been initialized but not yet started.""" + + RUNNING = 'RUNNING' + """Actor job is currently executing.""" + + SUCCEEDED = 'SUCCEEDED' + """Actor job completed successfully without errors.""" + + FAILED = 'FAILED' + """Actor job or build failed due to an error or exception.""" + + TIMING_OUT = 'TIMING-OUT' + """Actor job is currently in the process of timing out.""" + + TIMED_OUT = 'TIMED-OUT' + """Actor job was terminated due to timeout.""" + + ABORTING = 'ABORTING' + """Actor job is currently being aborted by user request.""" + + ABORTED = 'ABORTED' + """Actor job was successfully aborted by user request.""" + + @property + def is_terminal(self: ActorJobStatus) -> bool: + """Whether this Actor job status is terminal.""" + return self in ( + ActorJobStatus.SUCCEEDED, + ActorJobStatus.FAILED, + ActorJobStatus.TIMED_OUT, + ActorJobStatus.ABORTED, + ) + + +class WebhookEventType(str, Enum): + """Event types that can trigger webhook notifications. + + These events are sent to configured webhook URLs when specific + Actor run or build lifecycle events occur, enabling integration + with external systems and automated workflows. + """ + + ACTOR_RUN_CREATED = 'ACTOR.RUN.CREATED' + """Triggered when a new Actor run is created and initialized.""" + + ACTOR_RUN_SUCCEEDED = 'ACTOR.RUN.SUCCEEDED' + """Triggered when an Actor run completes successfully.""" + + ACTOR_RUN_FAILED = 'ACTOR.RUN.FAILED' + """Triggered when an Actor run fails due to an error.""" + + ACTOR_RUN_TIMED_OUT = 'ACTOR.RUN.TIMED_OUT' + """Triggered when an Actor run is terminated due to timeout.""" + + ACTOR_RUN_ABORTED = 'ACTOR.RUN.ABORTED' + """Triggered when an Actor run is manually aborted by user.""" + + ACTOR_RUN_RESURRECTED = 'ACTOR.RUN.RESURRECTED' + """Triggered when a previously failed Actor run is automatically resurrected.""" + + ACTOR_BUILD_CREATED = 'ACTOR.BUILD.CREATED' + """Triggered when a new Actor build process is initiated.""" + + ACTOR_BUILD_SUCCEEDED = 'ACTOR.BUILD.SUCCEEDED' + """Triggered when an Actor build completes successfully.""" + + ACTOR_BUILD_FAILED = 'ACTOR.BUILD.FAILED' + """Triggered when an Actor build fails due to compilation or setup errors.""" + + ACTOR_BUILD_TIMED_OUT = 'ACTOR.BUILD.TIMED_OUT' + """Triggered when an Actor build process exceeds the time limit.""" + + ACTOR_BUILD_ABORTED = 'ACTOR.BUILD.ABORTED' + """Triggered when an Actor build is manually cancelled by user.""" + + +class StorageGeneralAccess(str, Enum): + """Storage setting determining how others can access the storage. + + This setting overrides the user setting of the storage owner. + """ + + FOLLOW_USER_SETTING = 'FOLLOW_USER_SETTING' + """Respect the user setting of the storage owner (default behavior).""" + + RESTRICTED = 'RESTRICTED' + """Only signed-in users with explicit access can read this storage.""" + + ANYONE_WITH_ID_CAN_READ = 'ANYONE_WITH_ID_CAN_READ' + """Anyone with a link or the unique storage ID can read this storage.""" + + ANYONE_WITH_NAME_CAN_READ = 'ANYONE_WITH_NAME_CAN_READ' + """Anyone with a link, ID, or storage name can read this storage.""" + + +class RunGeneralAccess(str, Enum): + """Run setting determining how others can access the run. + + This setting overrides the user setting of the run owner. + """ + + FOLLOW_USER_SETTING = 'FOLLOW_USER_SETTING' + """Respect the user setting of the storage owner (default behavior).""" + + RESTRICTED = 'RESTRICTED' + """Only signed-in users with explicit access can read this run.""" + + ANYONE_WITH_ID_CAN_READ = 'ANYONE_WITH_ID_CAN_READ' + """Anyone with a link or the unique run ID can read this run.""" diff --git a/src/apify_client/_http_client.py b/src/apify_client/_http_client.py deleted file mode 100644 index 5f3c76d2..00000000 --- a/src/apify_client/_http_client.py +++ /dev/null @@ -1,292 +0,0 @@ -from __future__ import annotations - -import gzip -import json as jsonlib -import logging -import os -import sys -from datetime import datetime, timezone -from http import HTTPStatus -from importlib import metadata -from typing import TYPE_CHECKING, Any -from urllib.parse import urlencode - -import impit - -from apify_client._logging import log_context, logger_name -from apify_client._statistics import Statistics -from apify_client._utils import is_retryable_error, retry_with_exp_backoff, retry_with_exp_backoff_async -from apify_client.errors import ApifyApiError - -if TYPE_CHECKING: - from collections.abc import Callable - - from apify_client._types import JSONSerializable - -DEFAULT_BACKOFF_EXPONENTIAL_FACTOR = 2 -DEFAULT_BACKOFF_RANDOM_FACTOR = 1 - -logger = logging.getLogger(logger_name) - - -class _BaseHTTPClient: - def __init__( - self, - *, - token: str | None = None, - max_retries: int = 8, - min_delay_between_retries_millis: int = 500, - timeout_secs: int = 360, - stats: Statistics | None = None, - ) -> None: - self.max_retries = max_retries - self.min_delay_between_retries_millis = min_delay_between_retries_millis - self.timeout_secs = timeout_secs - - headers = {'Accept': 'application/json, */*'} - - workflow_key = os.getenv('APIFY_WORKFLOW_KEY') - if workflow_key is not None: - headers['X-Apify-Workflow-Key'] = workflow_key - - is_at_home = 'APIFY_IS_AT_HOME' in os.environ - python_version = '.'.join([str(x) for x in sys.version_info[:3]]) - client_version = metadata.version('apify-client') - - user_agent = f'ApifyClient/{client_version} ({sys.platform}; Python/{python_version}); isAtHome/{is_at_home}' - headers['User-Agent'] = user_agent - - if token is not None: - headers['Authorization'] = f'Bearer {token}' - - self.impit_client = impit.Client(headers=headers, follow_redirects=True, timeout=timeout_secs) - self.impit_async_client = impit.AsyncClient(headers=headers, follow_redirects=True, timeout=timeout_secs) - - self.stats = stats or Statistics() - - @staticmethod - def _parse_params(params: dict | None) -> dict | None: - if params is None: - return None - - parsed_params: dict = {} - for key, value in params.items(): - # Our API needs boolean parameters passed as 0 or 1 - if isinstance(value, bool): - parsed_params[key] = int(value) - # Our API needs lists passed as comma-separated strings - elif isinstance(value, list): - parsed_params[key] = ','.join(value) - elif isinstance(value, datetime): - utc_aware_dt = value.astimezone(timezone.utc) - - iso_str = utc_aware_dt.isoformat(timespec='milliseconds') - - # Convert to ISO 8601 string in Zulu format - zulu_date_str = iso_str.replace('+00:00', 'Z') - - parsed_params[key] = zulu_date_str - elif value is not None: - parsed_params[key] = value - - return parsed_params - - def _prepare_request_call( - self, - headers: dict | None = None, - params: dict | None = None, - data: Any = None, - json: JSONSerializable | None = None, - ) -> tuple[dict, dict | None, Any]: - if json and data: - raise ValueError('Cannot pass both "json" and "data" parameters at the same time!') - - if not headers: - headers = {} - - # dump JSON data to string, so they can be gzipped - if json: - data = jsonlib.dumps(json, ensure_ascii=False, allow_nan=False, default=str).encode('utf-8') - headers['Content-Type'] = 'application/json' - - if isinstance(data, (str, bytes, bytearray)): - if isinstance(data, str): - data = data.encode('utf-8') - data = gzip.compress(data) - headers['Content-Encoding'] = 'gzip' - - return ( - headers, - self._parse_params(params), - data, - ) - - def _build_url_with_params(self, url: str, params: dict | None = None) -> str: - if not params: - return url - - param_pairs: list[tuple[str, str]] = [] - for key, value in params.items(): - if isinstance(value, list): - param_pairs.extend((key, str(v)) for v in value) - else: - param_pairs.append((key, str(value))) - - query_string = urlencode(param_pairs) - - return f'{url}?{query_string}' - - -class HTTPClient(_BaseHTTPClient): - def call( - self, - *, - method: str, - url: str, - headers: dict | None = None, - params: dict | None = None, - data: Any = None, - json: JSONSerializable | None = None, - stream: bool | None = None, - timeout_secs: int | None = None, - ) -> impit.Response: - log_context.method.set(method) - log_context.url.set(url) - - self.stats.calls += 1 - - headers, params, content = self._prepare_request_call(headers, params, data, json) - - impit_client = self.impit_client - - def _make_request(stop_retrying: Callable, attempt: int) -> impit.Response: - log_context.attempt.set(attempt) - logger.debug('Sending request') - - self.stats.requests += 1 - - try: - # Increase timeout with each attempt. Max timeout is bounded by the client timeout. - timeout = min(self.timeout_secs, (timeout_secs or self.timeout_secs) * 2 ** (attempt - 1)) - - url_with_params = self._build_url_with_params(url, params) - - response = impit_client.request( - method=method, - url=url_with_params, - headers=headers, - content=content, - timeout=timeout, - stream=stream or False, - ) - - # If response status is < 300, the request was successful, and we can return the result - if response.status_code < 300: # noqa: PLR2004 - logger.debug('Request successful', extra={'status_code': response.status_code}) - - return response - - if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: - self.stats.add_rate_limit_error(attempt) - - except Exception as e: - logger.debug('Request threw exception', exc_info=e) - if not is_retryable_error(e): - logger.debug('Exception is not retryable', exc_info=e) - stop_retrying() - raise - - # We want to retry only requests which are server errors (status >= 500) and could resolve on their own, - # and also retry rate limited requests that throw 429 Too Many Requests errors - logger.debug('Request unsuccessful', extra={'status_code': response.status_code}) - if response.status_code < 500 and response.status_code != HTTPStatus.TOO_MANY_REQUESTS: # noqa: PLR2004 - logger.debug('Status code is not retryable', extra={'status_code': response.status_code}) - stop_retrying() - - # Read the response in case it is a stream, so we can raise the error properly - response.read() - raise ApifyApiError(response, attempt, method=method) - - return retry_with_exp_backoff( - _make_request, - max_retries=self.max_retries, - backoff_base_millis=self.min_delay_between_retries_millis, - backoff_factor=DEFAULT_BACKOFF_EXPONENTIAL_FACTOR, - random_factor=DEFAULT_BACKOFF_RANDOM_FACTOR, - ) - - -class HTTPClientAsync(_BaseHTTPClient): - async def call( - self, - *, - method: str, - url: str, - headers: dict | None = None, - params: dict | None = None, - data: Any = None, - json: JSONSerializable | None = None, - stream: bool | None = None, - timeout_secs: int | None = None, - ) -> impit.Response: - log_context.method.set(method) - log_context.url.set(url) - - self.stats.calls += 1 - - headers, params, content = self._prepare_request_call(headers, params, data, json) - - impit_async_client = self.impit_async_client - - async def _make_request(stop_retrying: Callable, attempt: int) -> impit.Response: - log_context.attempt.set(attempt) - logger.debug('Sending request') - try: - # Increase timeout with each attempt. Max timeout is bounded by the client timeout. - timeout = min(self.timeout_secs, (timeout_secs or self.timeout_secs) * 2 ** (attempt - 1)) - - url_with_params = self._build_url_with_params(url, params) - - response = await impit_async_client.request( - method=method, - url=url_with_params, - headers=headers, - content=content, - timeout=timeout, - stream=stream or False, - ) - - # If response status is < 300, the request was successful, and we can return the result - if response.status_code < 300: # noqa: PLR2004 - logger.debug('Request successful', extra={'status_code': response.status_code}) - - return response - - if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: - self.stats.add_rate_limit_error(attempt) - - except Exception as e: - logger.debug('Request threw exception', exc_info=e) - if not is_retryable_error(e): - logger.debug('Exception is not retryable', exc_info=e) - stop_retrying() - raise - - # We want to retry only requests which are server errors (status >= 500) and could resolve on their own, - # and also retry rate limited requests that throw 429 Too Many Requests errors - logger.debug('Request unsuccessful', extra={'status_code': response.status_code}) - if response.status_code < 500 and response.status_code != HTTPStatus.TOO_MANY_REQUESTS: # noqa: PLR2004 - logger.debug('Status code is not retryable', extra={'status_code': response.status_code}) - stop_retrying() - - # Read the response in case it is a stream, so we can raise the error properly - await response.aread() - raise ApifyApiError(response, attempt, method=method) - - return await retry_with_exp_backoff_async( - _make_request, - max_retries=self.max_retries, - backoff_base_millis=self.min_delay_between_retries_millis, - backoff_factor=DEFAULT_BACKOFF_EXPONENTIAL_FACTOR, - random_factor=DEFAULT_BACKOFF_RANDOM_FACTOR, - ) diff --git a/src/apify_client/_http_clients/__init__.py b/src/apify_client/_http_clients/__init__.py new file mode 100644 index 00000000..08844e65 --- /dev/null +++ b/src/apify_client/_http_clients/__init__.py @@ -0,0 +1,7 @@ +from ._async import HttpClientAsync +from ._sync import HttpClient + +__all__ = [ + 'HttpClient', + 'HttpClientAsync', +] diff --git a/src/apify_client/_http_clients/_async.py b/src/apify_client/_http_clients/_async.py new file mode 100644 index 00000000..8e51d0df --- /dev/null +++ b/src/apify_client/_http_clients/_async.py @@ -0,0 +1,143 @@ +from __future__ import annotations + +import asyncio +import logging +import random +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, TypeVar + +from apify_client._http_clients._base import BaseHttpClient +from apify_client._logging import log_context, logger_name +from apify_client.errors import ApifyApiError + +if TYPE_CHECKING: + from collections.abc import Awaitable, Callable + + import impit + + from apify_client._consts import JsonSerializable + +T = TypeVar('T') + +logger = logging.getLogger(logger_name) + + +class HttpClientAsync(BaseHttpClient): + async def call( + self, + *, + method: str, + url: str, + headers: dict | None = None, + params: dict | None = None, + data: Any = None, + json: JsonSerializable | None = None, + stream: bool | None = None, + timeout_secs: int | None = None, + ) -> impit.Response: + log_context.method.set(method) + log_context.url.set(url) + + self._statistics.calls += 1 + + headers, params, content = self._prepare_request_call(headers, params, data, json) + + impit_async_client = self.impit_async_client + + async def _make_request(stop_retrying: Callable, attempt: int) -> impit.Response: + log_context.attempt.set(attempt) + logger.debug('Sending request') + try: + # Increase timeout with each attempt. Max timeout is bounded by the client timeout. + timeout = min( + self._config.timeout_secs, (timeout_secs or self._config.timeout_secs) * 2 ** (attempt - 1) + ) + + url_with_params = self._build_url_with_params(url, params) + + response = await impit_async_client.request( + method=method, + url=url_with_params, + headers=headers, + content=content, + timeout=timeout, + stream=stream or False, + ) + + # If response status is < 300, the request was successful, and we can return the result + if response.status_code < 300: # noqa: PLR2004 + logger.debug('Request successful', extra={'status_code': response.status_code}) + + return response + + if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: + self._statistics.add_rate_limit_error(attempt) + + except Exception as exc: + logger.debug('Request threw exception', exc_info=exc) + if not self._is_retryable_error(exc): + logger.debug('Exception is not retryable', exc_info=exc) + stop_retrying() + raise + + # We want to retry only requests which are server errors (status >= 500) and could resolve on their own, + # and also retry rate limited requests that throw 429 Too Many Requests errors + logger.debug('Request unsuccessful', extra={'status_code': response.status_code}) + if response.status_code < 500 and response.status_code != HTTPStatus.TOO_MANY_REQUESTS: # noqa: PLR2004 + logger.debug('Status code is not retryable', extra={'status_code': response.status_code}) + stop_retrying() + + # Read the response in case it is a stream, so we can raise the error properly + await response.aread() + raise ApifyApiError(response, attempt, method=method) + + return await self._retry_with_exp_backoff( + _make_request, + max_retries=self._config.max_retries, + backoff_base_millis=self._config.min_delay_between_retries_millis, + ) + + @staticmethod + async def _retry_with_exp_backoff( + func: Callable[[Callable[[], None], int], Awaitable[T]], + *, + max_retries: int = 8, + backoff_base_millis: int = 500, + backoff_factor: float = 2, + random_factor: float = 1, + ) -> T: + """Retry a function with exponential backoff. + + Args: + func: Function to retry. Receives a stop_retrying callback and attempt number. + max_retries: Maximum number of retry attempts. + backoff_base_millis: Base backoff delay in milliseconds. + backoff_factor: Exponential backoff multiplier (1-10). + random_factor: Random jitter factor (0-1). + + Returns: + The return value of the function. + """ + random_factor = min(max(0, random_factor), 1) + backoff_factor = min(max(1, backoff_factor), 10) + swallow = True + + def stop_retrying() -> None: + nonlocal swallow + swallow = False + + for attempt in range(1, max_retries + 1): + try: + return await func(stop_retrying, attempt) + except Exception: + if not swallow: + raise + + random_sleep_factor = random.uniform(1, 1 + random_factor) + backoff_base_secs = backoff_base_millis / 1000 + backoff_exp_factor = backoff_factor ** (attempt - 1) + + sleep_time_secs = random_sleep_factor * backoff_base_secs * backoff_exp_factor + await asyncio.sleep(sleep_time_secs) + + return await func(stop_retrying, max_retries + 1) diff --git a/src/apify_client/_http_clients/_base.py b/src/apify_client/_http_clients/_base.py new file mode 100644 index 00000000..686205d4 --- /dev/null +++ b/src/apify_client/_http_clients/_base.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import gzip +import json as jsonlib +import os +import sys +from datetime import datetime, timezone +from importlib import metadata +from typing import TYPE_CHECKING, Any +from urllib.parse import urlencode + +import impit + +from apify_client._statistics import ClientStatistics +from apify_client.errors import InvalidResponseBodyError + +if TYPE_CHECKING: + from apify_client._config import ClientConfig + from apify_client._consts import JsonSerializable + + +class BaseHttpClient: + """Base class for HTTP clients with shared configuration and utilities.""" + + def __init__(self, config: ClientConfig, statistics: ClientStatistics | None = None) -> None: + """Initialize HTTP client with configuration. + + Args: + config: Immutable client configuration. + statistics: Optional statistics tracker. + """ + self._config = config + self._statistics = statistics or ClientStatistics() + + # Build headers + headers = {'Accept': 'application/json, */*'} + + workflow_key = os.getenv('APIFY_WORKFLOW_KEY') + if workflow_key is not None: + headers['X-Apify-Workflow-Key'] = workflow_key + + is_at_home = 'APIFY_IS_AT_HOME' in os.environ + python_version = '.'.join([str(x) for x in sys.version_info[:3]]) + client_version = metadata.version('apify-client') + + user_agent = f'ApifyClient/{client_version} ({sys.platform}; Python/{python_version}); isAtHome/{is_at_home}' + headers['User-Agent'] = user_agent + + if config.token is not None: + headers['Authorization'] = f'Bearer {config.token}' + + # Create impit clients + self.impit_client = impit.Client( + headers=headers, + follow_redirects=True, + timeout=config.timeout_secs, + ) + self.impit_async_client = impit.AsyncClient( + headers=headers, + follow_redirects=True, + timeout=config.timeout_secs, + ) + + @staticmethod + def _parse_params(params: dict | None) -> dict | None: + if params is None: + return None + + parsed_params: dict = {} + for key, value in params.items(): + # Our API needs boolean parameters passed as 0 or 1 + if isinstance(value, bool): + parsed_params[key] = int(value) + # Our API needs lists passed as comma-separated strings + elif isinstance(value, list): + parsed_params[key] = ','.join(value) + elif isinstance(value, datetime): + utc_aware_dt = value.astimezone(timezone.utc) + + iso_str = utc_aware_dt.isoformat(timespec='milliseconds') + + # Convert to ISO 8601 string in Zulu format + zulu_date_str = iso_str.replace('+00:00', 'Z') + + parsed_params[key] = zulu_date_str + elif value is not None: + parsed_params[key] = value + + return parsed_params + + @staticmethod + def _is_retryable_error(exc: Exception) -> bool: + """Check if an exception should be retried. + + Args: + exc: The exception to check. + + Returns: + True if the exception is retryable (network errors, timeouts, etc.). + """ + return isinstance( + exc, + ( + InvalidResponseBodyError, + impit.NetworkError, + impit.TimeoutException, + impit.RemoteProtocolError, + ), + ) + + def _prepare_request_call( + self, + headers: dict | None = None, + params: dict | None = None, + data: Any = None, + json: JsonSerializable | None = None, + ) -> tuple[dict, dict | None, Any]: + if json and data: + raise ValueError('Cannot pass both "json" and "data" parameters at the same time!') + + if not headers: + headers = {} + + # dump JSON data to string, so they can be gzipped + if json: + data = jsonlib.dumps(json, ensure_ascii=False, allow_nan=False, default=str).encode('utf-8') + headers['Content-Type'] = 'application/json' + + if isinstance(data, (str, bytes, bytearray)): + if isinstance(data, str): + data = data.encode('utf-8') + data = gzip.compress(data) + headers['Content-Encoding'] = 'gzip' + + return ( + headers, + self._parse_params(params), + data, + ) + + def _build_url_with_params(self, url: str, params: dict | None = None) -> str: + if not params: + return url + + param_pairs = list[tuple[str, str]]() + for key, value in params.items(): + if isinstance(value, list): + param_pairs.extend((key, str(v)) for v in value) + else: + param_pairs.append((key, str(value))) + + query_string = urlencode(param_pairs) + + return f'{url}?{query_string}' diff --git a/src/apify_client/_http_clients/_sync.py b/src/apify_client/_http_clients/_sync.py new file mode 100644 index 00000000..73224848 --- /dev/null +++ b/src/apify_client/_http_clients/_sync.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import logging +import random +import time +from http import HTTPStatus +from typing import TYPE_CHECKING, Any, TypeVar + +from apify_client._http_clients._base import BaseHttpClient +from apify_client._logging import log_context, logger_name +from apify_client.errors import ApifyApiError + +if TYPE_CHECKING: + from collections.abc import Callable + + import impit + + from apify_client._consts import JsonSerializable + +T = TypeVar('T') + +logger = logging.getLogger(logger_name) + + +class HttpClient(BaseHttpClient): + def call( + self, + *, + method: str, + url: str, + headers: dict | None = None, + params: dict | None = None, + data: Any = None, + json: JsonSerializable | None = None, + stream: bool | None = None, + timeout_secs: int | None = None, + ) -> impit.Response: + log_context.method.set(method) + log_context.url.set(url) + + self._statistics.calls += 1 + + headers, params, content = self._prepare_request_call(headers, params, data, json) + + impit_client = self.impit_client + + def _make_request(stop_retrying: Callable, attempt: int) -> impit.Response: + log_context.attempt.set(attempt) + logger.debug('Sending request') + + self._statistics.requests += 1 + + try: + # Increase timeout with each attempt. Max timeout is bounded by the client timeout. + timeout = min( + self._config.timeout_secs, (timeout_secs or self._config.timeout_secs) * 2 ** (attempt - 1) + ) + + url_with_params = self._build_url_with_params(url, params) + + response = impit_client.request( + method=method, + url=url_with_params, + headers=headers, + content=content, + timeout=timeout, + stream=stream or False, + ) + + # If response status is < 300, the request was successful, and we can return the result + if response.status_code < 300: # noqa: PLR2004 + logger.debug('Request successful', extra={'status_code': response.status_code}) + + return response + + if response.status_code == HTTPStatus.TOO_MANY_REQUESTS: + self._statistics.add_rate_limit_error(attempt) + + except Exception as exc: + logger.debug('Request threw exception', exc_info=exc) + if not self._is_retryable_error(exc): + logger.debug('Exception is not retryable', exc_info=exc) + stop_retrying() + raise + + # We want to retry only requests which are server errors (status >= 500) and could resolve on their own, + # and also retry rate limited requests that throw 429 Too Many Requests errors + logger.debug('Request unsuccessful', extra={'status_code': response.status_code}) + if response.status_code < 500 and response.status_code != HTTPStatus.TOO_MANY_REQUESTS: # noqa: PLR2004 + logger.debug('Status code is not retryable', extra={'status_code': response.status_code}) + stop_retrying() + + # Read the response in case it is a stream, so we can raise the error properly + response.read() + raise ApifyApiError(response, attempt, method=method) + + return self._retry_with_exp_backoff( + _make_request, + max_retries=self._config.max_retries, + backoff_base_millis=self._config.min_delay_between_retries_millis, + ) + + @staticmethod + def _retry_with_exp_backoff( + func: Callable[[Callable[[], None], int], T], + *, + max_retries: int = 8, + backoff_base_millis: int = 500, + backoff_factor: float = 2, + random_factor: float = 1, + ) -> T: + """Retry a function with exponential backoff. + + Args: + func: Function to retry. Receives a stop_retrying callback and attempt number. + max_retries: Maximum number of retry attempts. + backoff_base_millis: Base backoff delay in milliseconds. + backoff_factor: Exponential backoff multiplier (1-10). + random_factor: Random jitter factor (0-1). + + Returns: + The return value of the function. + """ + random_factor = min(max(0, random_factor), 1) + backoff_factor = min(max(1, backoff_factor), 10) + swallow = True + + def stop_retrying() -> None: + nonlocal swallow + swallow = False + + for attempt in range(1, max_retries + 1): + try: + return func(stop_retrying, attempt) + except Exception: + if not swallow: + raise + + random_sleep_factor = random.uniform(1, 1 + random_factor) + backoff_base_secs = backoff_base_millis / 1000 + backoff_exp_factor = backoff_factor ** (attempt - 1) + + sleep_time_secs = random_sleep_factor * backoff_base_secs * backoff_exp_factor + time.sleep(sleep_time_secs) + + return func(stop_retrying, max_retries + 1) diff --git a/src/apify_client/_logging.py b/src/apify_client/_logging.py index a5834701..f919720a 100644 --- a/src/apify_client/_logging.py +++ b/src/apify_client/_logging.py @@ -2,7 +2,6 @@ import functools import inspect -import json import logging from contextvars import ContextVar from typing import TYPE_CHECKING, Any, NamedTuple @@ -12,18 +11,21 @@ if TYPE_CHECKING: from collections.abc import Callable - from apify_client.clients.base.base_client import _BaseBaseClient + from apify_client._resource_clients._resource_client import ResourceClient + + _BaseClient = ResourceClient -# Name of the logger used throughout the library logger_name = __name__.split('.')[0] +"""Name of the logger used throughout the library.""" -# Logger used throughout the library logger = logging.getLogger(logger_name) +"""Logger used throughout the library.""" -# Context containing the details of the request and the resource client making the request class LogContext(NamedTuple): + """Request context details for logging (attempt, client method, HTTP method, resource ID, URL).""" + attempt: ContextVar[int | None] client_method: ContextVar[str | None] method: ContextVar[str | None] @@ -40,10 +42,11 @@ class LogContext(NamedTuple): ) -# Metaclass for resource clients which wraps all their public methods -# With injection of their details to the log context vars class WithLogDetailsClient(type): + """Metaclass that wraps public methods to inject client details into log context.""" + def __new__(cls, name: str, bases: tuple, attrs: dict) -> WithLogDetailsClient: + """Wrap all public methods in the class with logging context injection.""" for attr_name, attr_value in attrs.items(): if not attr_name.startswith('_') and inspect.isfunction(attr_value): attrs[attr_name] = _injects_client_details_to_log_context(attr_value) @@ -51,47 +54,52 @@ def __new__(cls, name: str, bases: tuple, attrs: dict) -> WithLogDetailsClient: return type.__new__(cls, name, bases, attrs) -# Wraps an unbound method so that its call will inject the details -# of the resource client (which is the `self` argument of the method) -# to the log context vars -def _injects_client_details_to_log_context(fun: Callable) -> Callable: - if inspect.iscoroutinefunction(fun): +class RedirectLogFormatter(logging.Formatter): + """Log formatter that prepends colored logger name to messages.""" - @functools.wraps(fun) - async def async_wrapper(resource_client: _BaseBaseClient, *args: Any, **kwargs: Any) -> Any: - log_context.client_method.set(fun.__qualname__) # ty: ignore[unresolved-attribute] - log_context.resource_id.set(resource_client.resource_id) + def format(self, record: logging.LogRecord) -> str: + """Format log by prepending colored logger name. - return await fun(resource_client, *args, **kwargs) + Args: + record: The log record to format. - return async_wrapper - elif inspect.isasyncgenfunction(fun): # noqa: RET505 + Returns: + Formatted log message with colored logger name prefix. + """ + formatted_logger_name = f'{Fore.CYAN}[{record.name}]{Style.RESET_ALL}' + return f'{formatted_logger_name} -> {record.msg}' - @functools.wraps(fun) - async def async_generator_wrapper(resource_client: _BaseBaseClient, *args: Any, **kwargs: Any) -> Any: - log_context.client_method.set(fun.__qualname__) # ty: ignore[unresolved-attribute] - log_context.resource_id.set(resource_client.resource_id) - async for item in fun(resource_client, *args, **kwargs): - yield item +def create_redirect_logger(name: str) -> logging.Logger: + """Create a logger for redirecting logs from another Actor. - return async_generator_wrapper - else: + Args: + name: Logger name. Use dot notation for hierarchy (e.g., "apify.xyz" creates "xyz" under "apify"). - @functools.wraps(fun) - def wrapper(resource_client: _BaseBaseClient, *args: Any, **kwargs: Any) -> Any: - log_context.client_method.set(fun.__qualname__) # ty: ignore[unresolved-attribute] - log_context.resource_id.set(resource_client.resource_id) + Returns: + Configured logger with RedirectLogFormatter. + """ + to_logger = logging.getLogger(name) + to_logger.propagate = False - return fun(resource_client, *args, **kwargs) + # Remove filters and handlers in case this logger already exists and was set up in some way. + for handler in to_logger.handlers: + to_logger.removeHandler(handler) + for log_filter in to_logger.filters: + to_logger.removeFilter(log_filter) - return wrapper + handler = logging.StreamHandler() + handler.setFormatter(RedirectLogFormatter()) + to_logger.addHandler(handler) + to_logger.setLevel(logging.DEBUG) + return to_logger -# A filter which lets every log record through, -# but adds the current logging context to the record class _ContextInjectingFilter(logging.Filter): + """Filter that injects current log context into all log records.""" + def filter(self, record: logging.LogRecord) -> bool: + """Add log context variables to the record.""" record.client_method = log_context.client_method.get() record.resource_id = log_context.resource_id.get() record.method = log_context.method.get() @@ -100,71 +108,39 @@ def filter(self, record: logging.LogRecord) -> bool: return True -logger.addFilter(_ContextInjectingFilter()) - - -# Log formatter useful for debugging of the client -# Will print out all the extra fields added to the log record -class _DebugLogFormatter(logging.Formatter): - empty_record = logging.LogRecord('dummy', 0, 'dummy', 0, 'dummy', None, None) - - # Gets the extra fields from the log record which are not present on an empty record - def _get_extra_fields(self, record: logging.LogRecord) -> dict: - extra_fields: dict = {} - for key, value in record.__dict__.items(): - if key not in self.empty_record.__dict__: - extra_fields[key] = value # noqa: PERF403 - - return extra_fields - - def format(self, record: logging.LogRecord) -> str: - extra = self._get_extra_fields(record) +def _injects_client_details_to_log_context(fun: Callable) -> Callable: + """Wrap a method to inject resource client details into log context before execution.""" + if inspect.iscoroutinefunction(fun): - log_string = super().format(record) - if extra: - log_string = f'{log_string} ({json.dumps(extra)})' - return log_string + @functools.wraps(fun) + async def async_wrapper(resource_client: _BaseClient, *args: Any, **kwargs: Any) -> Any: + log_context.client_method.set(fun.__qualname__) # ty: ignore[unresolved-attribute] + log_context.resource_id.set(resource_client._resource_id) # noqa: SLF001 + return await fun(resource_client, *args, **kwargs) -def create_redirect_logger( - name: str, -) -> logging.Logger: - """Create a logger for redirecting logs from another Actor. + return async_wrapper - Args: - name: The name of the logger. It can be used to inherit from other loggers. Example: `apify.xyz` will use logger - named `xyz` and make it a children of `apify` logger. + if inspect.isasyncgenfunction(fun): - Returns: - The created logger. - """ - to_logger = logging.getLogger(name) - to_logger.propagate = False + @functools.wraps(fun) + async def async_generator_wrapper(resource_client: _BaseClient, *args: Any, **kwargs: Any) -> Any: + log_context.client_method.set(fun.__qualname__) # ty: ignore[unresolved-attribute] + log_context.resource_id.set(resource_client._resource_id) # noqa: SLF001 - # Remove filters and handlers in case this logger already exists and was set up in some way. - for handler in to_logger.handlers: - to_logger.removeHandler(handler) - for log_filter in to_logger.filters: - to_logger.removeFilter(log_filter) + async for item in fun(resource_client, *args, **kwargs): + yield item - handler = logging.StreamHandler() - handler.setFormatter(RedirectLogFormatter()) - to_logger.addHandler(handler) - to_logger.setLevel(logging.DEBUG) - return to_logger + return async_generator_wrapper + @functools.wraps(fun) + def wrapper(resource_client: _BaseClient, *args: Any, **kwargs: Any) -> Any: + log_context.client_method.set(fun.__qualname__) # ty: ignore[unresolved-attribute] + log_context.resource_id.set(resource_client._resource_id) # noqa: SLF001 -class RedirectLogFormatter(logging.Formatter): - """Formatter applied to default redirect logger.""" + return fun(resource_client, *args, **kwargs) - def format(self, record: logging.LogRecord) -> str: - """Format the log by prepending logger name to the original message. + return wrapper - Args: - record: Log record to be formatted. - Returns: - Formatted log message. - """ - formatted_logger_name = f'{Fore.CYAN}[{record.name}]{Style.RESET_ALL}' - return f'{formatted_logger_name} -> {record.msg}' +logger.addFilter(_ContextInjectingFilter()) diff --git a/src/apify_client/_models.py b/src/apify_client/_models.py new file mode 100644 index 00000000..881b06e2 --- /dev/null +++ b/src/apify_client/_models.py @@ -0,0 +1,2565 @@ +# generated by datamodel-codegen: +# filename: openapi.json +# timestamp: 2026-01-23T15:26:19+00:00 + +from __future__ import annotations + +from enum import Enum, IntEnum +from ipaddress import IPv4Address +from typing import Annotated, Any, Literal + +from pydantic import AnyUrl, AwareDatetime, BaseModel, ConfigDict, EmailStr, Field + + +class PaginationResponse(BaseModel): + """Common pagination fields for list responses.""" + + total: Annotated[int, Field(examples=[2], ge=0)] + """ + The total number of items available across all pages. + """ + offset: Annotated[int, Field(examples=[0], ge=0)] + """ + The starting position for this page of results. + """ + limit: Annotated[int, Field(examples=[1000], ge=1)] + """ + The maximum number of items returned per page. + """ + desc: Annotated[bool, Field(examples=[False])] + """ + Whether the results are sorted in descending order. + """ + count: Annotated[int, Field(examples=[2], ge=0)] + """ + The number of items returned in this response. + """ + + +class ActorStats(BaseModel): + total_builds: Annotated[int | None, Field(alias='totalBuilds', examples=[9])] = None + total_runs: Annotated[int | None, Field(alias='totalRuns', examples=[16])] = None + total_users: Annotated[int | None, Field(alias='totalUsers', examples=[6])] = None + total_users7_days: Annotated[int | None, Field(alias='totalUsers7Days', examples=[2])] = None + total_users30_days: Annotated[int | None, Field(alias='totalUsers30Days', examples=[6])] = None + total_users90_days: Annotated[int | None, Field(alias='totalUsers90Days', examples=[6])] = None + total_metamorphs: Annotated[int | None, Field(alias='totalMetamorphs', examples=[2])] = None + last_run_started_at: Annotated[ + AwareDatetime | None, Field(alias='lastRunStartedAt', examples=['2019-07-08T14:01:05.546Z']) + ] = None + + +class ActorShort(BaseModel): + id: Annotated[str, Field(examples=['br9CKmk457'])] + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2019-10-29T07:34:24.202Z'])] + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2019-10-30T07:34:24.202Z'])] + name: Annotated[str, Field(examples=['MyAct'])] + username: Annotated[str, Field(examples=['janedoe'])] + title: Annotated[str | None, Field(examples=['Hello World Example'])] = None + stats: ActorStats | None = None + + +class ListOfActors(PaginationResponse): + items: list[ActorShort] + + +class GetListOfActorsResponse(BaseModel): + data: ListOfActors + + +class Error(BaseModel): + type: Annotated[str, Field(examples=['run-failed'])] + message: Annotated[str, Field(examples=['Actor run did not succeed (run ID: 55uatRrZib4xbZs, status: FAILED)'])] + + +class ErrorResponse(BaseModel): + error: Error + + +class VersionSourceType(Enum): + SOURCE_FILES = 'SOURCE_FILES' + GIT_REPO = 'GIT_REPO' + TARBALL = 'TARBALL' + GITHUB_GIST = 'GITHUB_GIST' + + +class EnvVar(BaseModel): + name: Annotated[str, Field(examples=['MY_ENV_VAR'])] + value: Annotated[str, Field(examples=['my-value'])] + is_secret: Annotated[bool | None, Field(alias='isSecret', examples=[False])] = None + + +class SourceCodeFileFormat(Enum): + BASE64 = 'BASE64' + TEXT = 'TEXT' + + +class SourceCodeFile(BaseModel): + format: SourceCodeFileFormat + content: Annotated[str, Field(examples=["console.log('This is the main.js file');"])] + name: Annotated[str, Field(examples=['src/main.js'])] + + +class Folder(Enum): + BOOLEAN_TRUE = True + + +class SourceCodeFolder(BaseModel): + name: Annotated[str, Field(examples=['src/placeholder'])] + folder: Annotated[Folder, Field(examples=[True])] + + +class Version(BaseModel): + version_number: Annotated[str, Field(alias='versionNumber', examples=['0.0'])] + source_type: Annotated[VersionSourceType | None, Field(alias='sourceType')] = None + env_vars: Annotated[list[EnvVar] | None, Field(alias='envVars')] = None + apply_env_vars_to_build: Annotated[bool | None, Field(alias='applyEnvVarsToBuild', examples=[False])] = None + build_tag: Annotated[str | None, Field(alias='buildTag', examples=['latest'])] = None + source_files: Annotated[ + list[SourceCodeFile | SourceCodeFolder] | None, Field(alias='sourceFiles', title='VersionSourceFiles') + ] = None + + +class CommonActorPricingInfo(BaseModel): + apify_margin_percentage: Annotated[float, Field(alias='apifyMarginPercentage')] + """ + In [0, 1], fraction of pricePerUnitUsd that goes to Apify + """ + created_at: Annotated[AwareDatetime, Field(alias='createdAt')] + """ + When this pricing info record has been created + """ + started_at: Annotated[AwareDatetime, Field(alias='startedAt')] + """ + Since when is this pricing info record effective for a given Actor + """ + notified_about_future_change_at: Annotated[AwareDatetime | None, Field(alias='notifiedAboutFutureChangeAt')] = None + notified_about_change_at: Annotated[AwareDatetime | None, Field(alias='notifiedAboutChangeAt')] = None + reason_for_change: Annotated[str | None, Field(alias='reasonForChange')] = None + + +class PricingModel(Enum): + PAY_PER_EVENT = 'PAY_PER_EVENT' + PRICE_PER_DATASET_ITEM = 'PRICE_PER_DATASET_ITEM' + FLAT_PRICE_PER_MONTH = 'FLAT_PRICE_PER_MONTH' + FREE = 'FREE' + + +class ActorChargeEvent(BaseModel): + event_price_usd: Annotated[float, Field(alias='eventPriceUsd')] + event_title: Annotated[str, Field(alias='eventTitle')] + event_description: Annotated[str, Field(alias='eventDescription')] + + +class PricingPerEvent(BaseModel): + actor_charge_events: Annotated[dict[str, ActorChargeEvent] | None, Field(alias='actorChargeEvents')] = None + + +class PayPerEventActorPricingInfo(CommonActorPricingInfo): + pricing_model: Annotated[Literal['PAY_PER_EVENT'], Field(alias='pricingModel')] + pricing_per_event: Annotated[PricingPerEvent, Field(alias='pricingPerEvent')] + minimal_max_total_charge_usd: Annotated[float | None, Field(alias='minimalMaxTotalChargeUsd')] = None + + +class PricePerDatasetItemActorPricingInfo(CommonActorPricingInfo): + pricing_model: Annotated[Literal['PRICE_PER_DATASET_ITEM'], Field(alias='pricingModel')] + unit_name: Annotated[str, Field(alias='unitName')] + """ + Name of the unit that is being charged + """ + price_per_unit_usd: Annotated[float, Field(alias='pricePerUnitUsd')] + + +class FlatPricePerMonthActorPricingInfo(CommonActorPricingInfo): + pricing_model: Annotated[Literal['FLAT_PRICE_PER_MONTH'], Field(alias='pricingModel')] + trial_minutes: Annotated[int, Field(alias='trialMinutes')] + """ + For how long this Actor can be used for free in trial period + """ + price_per_unit_usd: Annotated[float, Field(alias='pricePerUnitUsd')] + """ + Monthly flat price in USD + """ + + +class FreeActorPricingInfo(CommonActorPricingInfo): + pricing_model: Annotated[Literal['FREE'], Field(alias='pricingModel')] + + +class DefaultRunOptions(BaseModel): + build: Annotated[str, Field(examples=['latest'])] + timeout_secs: Annotated[int | None, Field(alias='timeoutSecs', examples=[3600])] = None + memory_mbytes: Annotated[int, Field(alias='memoryMbytes', examples=[2048])] + restart_on_error: Annotated[bool | None, Field(alias='restartOnError', examples=[False])] = None + + +class CreateActorRequest(BaseModel): + name: Annotated[str | None, Field(examples=['MyActor'])] = None + description: Annotated[str | None, Field(examples=['My favourite actor!'])] = None + title: Annotated[str | None, Field(examples=['My actor'])] = None + is_public: Annotated[bool | None, Field(alias='isPublic', examples=[False])] = None + seo_title: Annotated[str | None, Field(alias='seoTitle', examples=['My actor'])] = None + seo_description: Annotated[str | None, Field(alias='seoDescription', examples=['My actor is the best'])] = None + restart_on_error: Annotated[bool | None, Field(alias='restartOnError', examples=[False])] = None + versions: list[Version] | None = None + pricing_infos: Annotated[ + list[ + PayPerEventActorPricingInfo + | PricePerDatasetItemActorPricingInfo + | FlatPricePerMonthActorPricingInfo + | FreeActorPricingInfo + ] + | None, + Field(alias='pricingInfos'), + ] = None + categories: list[str] | None = None + default_run_options: Annotated[DefaultRunOptions | None, Field(alias='defaultRunOptions')] = None + + +class ActorPermissionLevel(Enum): + """Determines permissions that the Actor requires to run. For more information, see the [Actor permissions documentation](https://docs.apify.com/platform/actors/development/permissions).""" + + LIMITED_PERMISSIONS = 'LIMITED_PERMISSIONS' + FULL_PERMISSIONS = 'FULL_PERMISSIONS' + + +class ExampleRunInput(BaseModel): + body: Annotated[str, Field(examples=['{ "helloWorld": 123 }'])] + content_type: Annotated[str, Field(alias='contentType', examples=['application/json; charset=utf-8'])] + + +class Latest(BaseModel): + build_id: Annotated[str | None, Field(alias='buildId', examples=['z2EryhbfhgSyqj6Hn'])] = None + build_number: Annotated[str | None, Field(alias='buildNumber', examples=['0.0.2'])] = None + finished_at: Annotated[AwareDatetime | None, Field(alias='finishedAt', examples=['2019-06-10T11:15:49.286Z'])] = ( + None + ) + + +class TaggedBuilds(BaseModel): + latest: Latest | None = None + + +class Actor(BaseModel): + id: Annotated[str, Field(examples=['zdc3Pyhyz3m8vjDeM'])] + user_id: Annotated[str, Field(alias='userId', examples=['wRsJZtadYvn4mBZmm'])] + name: Annotated[str, Field(examples=['MyActor'])] + username: Annotated[str, Field(examples=['jane35'])] + description: Annotated[str | None, Field(examples=['My favourite actor!'])] = None + restart_on_error: Annotated[bool | None, Field(alias='restartOnError', examples=[False])] = None + is_public: Annotated[bool, Field(alias='isPublic', examples=[False])] + actor_permission_level: Annotated[ActorPermissionLevel | None, Field(alias='actorPermissionLevel')] = None + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2019-07-08T11:27:57.401Z'])] + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2019-07-08T14:01:05.546Z'])] + stats: ActorStats + versions: list[Version] + pricing_infos: Annotated[ + list[ + PayPerEventActorPricingInfo + | PricePerDatasetItemActorPricingInfo + | FlatPricePerMonthActorPricingInfo + | FreeActorPricingInfo + ] + | None, + Field(alias='pricingInfos'), + ] = None + default_run_options: Annotated[DefaultRunOptions, Field(alias='defaultRunOptions')] + example_run_input: Annotated[ExampleRunInput | None, Field(alias='exampleRunInput')] = None + is_deprecated: Annotated[bool | None, Field(alias='isDeprecated', examples=[False])] = None + deployment_key: Annotated[str | None, Field(alias='deploymentKey', examples=['ssh-rsa AAAA ...'])] = None + title: Annotated[str | None, Field(examples=['My Actor'])] = None + tagged_builds: Annotated[TaggedBuilds | None, Field(alias='taggedBuilds')] = None + + +class CreateActorResponse(BaseModel): + data: Actor + + +class GetActorResponse(BaseModel): + data: Actor + + +class CreateOrUpdateVersionRequest(BaseModel): + version_number: Annotated[str | None, Field(alias='versionNumber', examples=['0.0'])] = None + source_type: Annotated[VersionSourceType | None, Field(alias='sourceType')] = None + env_vars: Annotated[list[EnvVar] | None, Field(alias='envVars')] = None + apply_env_vars_to_build: Annotated[bool | None, Field(alias='applyEnvVarsToBuild', examples=[False])] = None + build_tag: Annotated[str | None, Field(alias='buildTag', examples=['latest'])] = None + source_files: Annotated[ + list[SourceCodeFile | SourceCodeFolder] | None, Field(alias='sourceFiles', title='VersionSourceFiles') + ] = None + + +class BuildTag(BaseModel): + build_id: Annotated[str, Field(alias='buildId')] + + +class UpdateActorRequest(BaseModel): + name: Annotated[str, Field(examples=['MyActor'])] + description: Annotated[str | None, Field(examples=['My favourite actor!'])] = None + is_public: Annotated[bool, Field(alias='isPublic', examples=[False])] + actor_permission_level: Annotated[ActorPermissionLevel | None, Field(alias='actorPermissionLevel')] = None + seo_title: Annotated[str | None, Field(alias='seoTitle', examples=['My actor'])] = None + seo_description: Annotated[str | None, Field(alias='seoDescription', examples=['My actor is the best'])] = None + title: Annotated[str | None, Field(examples=['My Actor'])] = None + restart_on_error: Annotated[bool | None, Field(alias='restartOnError', examples=[False])] = None + versions: list[CreateOrUpdateVersionRequest] + pricing_infos: Annotated[ + list[ + PayPerEventActorPricingInfo + | PricePerDatasetItemActorPricingInfo + | FlatPricePerMonthActorPricingInfo + | FreeActorPricingInfo + ] + | None, + Field(alias='pricingInfos'), + ] = None + categories: list[str] | None = None + default_run_options: Annotated[DefaultRunOptions | None, Field(alias='defaultRunOptions')] = None + tagged_builds: Annotated[ + dict[str, Any] | None, + Field(alias='taggedBuilds', examples=[{'latest': {'buildId': 'z2EryhbfhgSyqj6Hn'}, 'beta': None}]), + ] = None + """ + An object to modify tags on the Actor's builds. The key is the tag name (e.g., _latest_), and the value is either an object with a `buildId` or `null`. + + This operation is a patch; any existing tags that you omit from this object will be preserved. + + - **To create or reassign a tag**, provide the tag name with a `buildId`. e.g., to assign the _latest_ tag: + +   + + ```json + { + "latest": { + "buildId": "z2EryhbfhgSyqj6Hn" + } + } + ``` + + - **To remove a tag**, provide the tag name with a `null` value. e.g., to remove the _beta_ tag: + +   + + ```json + { + "beta": null + } + ``` + + - **To perform multiple operations**, combine them. The following reassigns _latest_ and removes _beta_, while preserving any other existing tags. + +   + + ```json + { + "latest": { + "buildId": "z2EryhbfhgSyqj6Hn" + }, + "beta": null + } + ``` + + """ + + +class UpdateActorResponse(BaseModel): + data: Actor + + +class ListOfVersions(BaseModel): + total: Annotated[int, Field(examples=[5])] + items: list[Version] + + +class GetListOfVersionsResponse(BaseModel): + data: ListOfVersions + + +class GetVersionResponse(BaseModel): + data: Version + + +class ListOfEnvVars(BaseModel): + total: Annotated[int, Field(examples=[5])] + items: list[EnvVar] + + +class GetListOfEnvVarsResponse(BaseModel): + data: ListOfEnvVars + + +class CreateOrUpdateEnvVarRequest(BaseModel): + name: Annotated[str, Field(examples=['MY_ENV_VAR'])] + value: Annotated[str, Field(examples=['my-new-value'])] + is_secret: Annotated[bool | None, Field(alias='isSecret', examples=[False])] = None + + +class GetEnvVarResponse(BaseModel): + data: EnvVar + + +class WebhookEventType(Enum): + """Type of event that triggers the webhook.""" + + ACTOR_BUILD_ABORTED = 'ACTOR.BUILD.ABORTED' + ACTOR_BUILD_CREATED = 'ACTOR.BUILD.CREATED' + ACTOR_BUILD_FAILED = 'ACTOR.BUILD.FAILED' + ACTOR_BUILD_SUCCEEDED = 'ACTOR.BUILD.SUCCEEDED' + ACTOR_BUILD_TIMED_OUT = 'ACTOR.BUILD.TIMED_OUT' + ACTOR_RUN_ABORTED = 'ACTOR.RUN.ABORTED' + ACTOR_RUN_CREATED = 'ACTOR.RUN.CREATED' + ACTOR_RUN_FAILED = 'ACTOR.RUN.FAILED' + ACTOR_RUN_RESURRECTED = 'ACTOR.RUN.RESURRECTED' + ACTOR_RUN_SUCCEEDED = 'ACTOR.RUN.SUCCEEDED' + ACTOR_RUN_TIMED_OUT = 'ACTOR.RUN.TIMED_OUT' + + +class WebhookCondition(BaseModel): + actor_id: Annotated[str | None, Field(alias='actorId', examples=['hksJZtadYvn4mBuin'])] = None + actor_task_id: Annotated[str | None, Field(alias='actorTaskId', examples=['asdLZtadYvn4mBZmm'])] = None + actor_run_id: Annotated[str | None, Field(alias='actorRunId', examples=['hgdKZtadYvn4mBpoi'])] = None + + +class ExampleWebhookDispatch(BaseModel): + status: Annotated[str, Field(examples=['SUCCEEDED'])] + finished_at: Annotated[AwareDatetime, Field(alias='finishedAt', examples=['2019-12-13T08:36:13.202Z'])] + + +class WebhookStats(BaseModel): + total_dispatches: Annotated[int, Field(alias='totalDispatches', examples=[1])] + + +class WebhookShort(BaseModel): + id: Annotated[str, Field(examples=['YiKoxjkaS9gjGTqhF'])] + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2019-12-12T07:34:14.202Z'])] + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2019-12-13T08:36:13.202Z'])] + user_id: Annotated[str, Field(alias='userId', examples=['wRsJZtadYvn4mBZmm'])] + is_ad_hoc: Annotated[bool | None, Field(alias='isAdHoc', examples=[False])] = None + should_interpolate_strings: Annotated[bool | None, Field(alias='shouldInterpolateStrings', examples=[False])] = None + event_types: Annotated[list[WebhookEventType], Field(alias='eventTypes', examples=[['ACTOR.RUN.SUCCEEDED']])] + condition: WebhookCondition + ignore_ssl_errors: Annotated[bool, Field(alias='ignoreSslErrors', examples=[False])] + do_not_retry: Annotated[bool, Field(alias='doNotRetry', examples=[False])] + request_url: Annotated[AnyUrl, Field(alias='requestUrl', examples=['http://example.com/'])] + last_dispatch: Annotated[ExampleWebhookDispatch | None, Field(alias='lastDispatch')] = None + stats: WebhookStats | None = None + + +class ListOfWebhooks(PaginationResponse): + items: list[WebhookShort] + + +class GetListOfWebhooksResponse(BaseModel): + data: ListOfWebhooks + + +class ActorJobStatus(Enum): + """Status of an Actor job (run or build).""" + + READY = 'READY' + RUNNING = 'RUNNING' + SUCCEEDED = 'SUCCEEDED' + FAILED = 'FAILED' + TIMING_OUT = 'TIMING-OUT' + TIMED_OUT = 'TIMED-OUT' + ABORTING = 'ABORTING' + ABORTED = 'ABORTED' + + +class BuildsMeta(BaseModel): + origin: Annotated[str, Field(examples=['WEB'])] + client_ip: Annotated[IPv4Address | None, Field(alias='clientIp', examples=['172.234.12.34'])] = None + user_agent: Annotated[str | None, Field(alias='userAgent', examples=['Mozilla/5.0 (iPad)'])] = None + + +class BuildShort(BaseModel): + id: Annotated[str, Field(examples=['HG7ML7M8z78YcAPEB'])] + act_id: Annotated[str | None, Field(alias='actId', examples=['janedoe~my-actor'])] = None + status: ActorJobStatus + started_at: Annotated[AwareDatetime, Field(alias='startedAt', examples=['2019-11-30T07:34:24.202Z'])] + finished_at: Annotated[AwareDatetime, Field(alias='finishedAt', examples=['2019-12-12T09:30:12.202Z'])] + usage_total_usd: Annotated[float, Field(alias='usageTotalUsd', examples=[0.02])] + meta: BuildsMeta | None = None + + +class ListOfBuilds(PaginationResponse): + items: list[BuildShort] + + +class GetListOfBuildsResponse(BaseModel): + data: ListOfBuilds + + +class BuildStats(BaseModel): + duration_millis: Annotated[int | None, Field(alias='durationMillis', examples=[1000])] = None + run_time_secs: Annotated[float | None, Field(alias='runTimeSecs', examples=[45.718])] = None + compute_units: Annotated[float, Field(alias='computeUnits', examples=[0.0126994444444444])] + + +class BuildOptions(BaseModel): + use_cache: Annotated[bool | None, Field(alias='useCache', examples=[False])] = None + beta_packages: Annotated[bool | None, Field(alias='betaPackages', examples=[False])] = None + memory_mbytes: Annotated[int | None, Field(alias='memoryMbytes', examples=[1024])] = None + disk_mbytes: Annotated[int | None, Field(alias='diskMbytes', examples=[2048])] = None + + +class BuildUsage(BaseModel): + actor_compute_units: Annotated[float | None, Field(alias='ACTOR_COMPUTE_UNITS', examples=[0.08])] = None + + +class ActorSpecification(IntEnum): + """The Actor specification version that this Actor follows. This property must be set to 1.""" + + INTEGER_1 = 1 + + +class Storages(BaseModel): + dataset: dict[str, Any] | None = None + """ + Defines the schema of items in your dataset, the full specification can be found in [Apify docs](https://docs.apify.com/platform/actors/development/actor-definition/dataset-schema) + """ + + +class ActorDefinition(BaseModel): + """The definition of the Actor, the full specification of this field can be found in [Apify docs](https://docs.apify.com/platform/actors/development/actor-definition/actor-json)""" + + actor_specification: Annotated[ActorSpecification | None, Field(alias='actorSpecification')] = None + """ + The Actor specification version that this Actor follows. This property must be set to 1. + """ + name: str | None = None + """ + The name of the Actor. + """ + version: Annotated[str | None, Field(pattern='^[0-9]+\\.[0-9]+$')] = None + """ + The version of the Actor, specified in the format [Number].[Number], e.g., 0.1, 1.0. + """ + build_tag: Annotated[str | None, Field(alias='buildTag')] = None + """ + The tag name to be applied to a successful build of the Actor. Defaults to 'latest' if not specified. + """ + environment_variables: Annotated[dict[str, str] | None, Field(alias='environmentVariables')] = None + """ + A map of environment variables to be used during local development and deployment. + """ + dockerfile: str | None = None + """ + The path to the Dockerfile used for building the Actor on the platform. + """ + docker_context_dir: Annotated[str | None, Field(alias='dockerContextDir')] = None + """ + The path to the directory used as the Docker context when building the Actor. + """ + readme: str | None = None + """ + The path to the README file for the Actor. + """ + input: dict[str, Any] | None = None + """ + The input schema object, the full specification can be found in [Apify docs](https://docs.apify.com/platform/actors/development/actor-definition/input-schema) + """ + changelog: str | None = None + """ + The path to the CHANGELOG file displayed in the Actor's information tab. + """ + storages: Storages | None = None + default_memory_mbytes: Annotated[str | int | None, Field(alias='defaultMemoryMbytes')] = None + """ + Specifies the default amount of memory in megabytes to be used when the Actor is started. Can be an integer or a [dynamic memory expression](/platform/actors/development/actor-definition/dynamic-actor-memory). + """ + min_memory_mbytes: Annotated[int | None, Field(alias='minMemoryMbytes', ge=256)] = None + """ + Specifies the minimum amount of memory in megabytes required by the Actor. + """ + max_memory_mbytes: Annotated[int | None, Field(alias='maxMemoryMbytes', ge=256)] = None + """ + Specifies the maximum amount of memory in megabytes required by the Actor. + """ + uses_standby_mode: Annotated[bool | None, Field(alias='usesStandbyMode')] = None + """ + Specifies whether the Actor will have Standby mode enabled. + """ + + +class Build(BaseModel): + id: Annotated[str, Field(examples=['HG7ML7M8z78YcAPEB'])] + act_id: Annotated[str, Field(alias='actId', examples=['janedoe~my-actor'])] + user_id: Annotated[str, Field(alias='userId', examples=['klmdEpoiojmdEMlk3'])] + started_at: Annotated[AwareDatetime, Field(alias='startedAt', examples=['2019-11-30T07:34:24.202Z'])] + finished_at: Annotated[AwareDatetime | None, Field(alias='finishedAt', examples=['2019-12-12T09:30:12.202Z'])] = ( + None + ) + status: ActorJobStatus + meta: BuildsMeta + stats: BuildStats | None = None + options: BuildOptions | None = None + usage: BuildUsage | None = None + usage_total_usd: Annotated[float | None, Field(alias='usageTotalUsd', examples=[0.02])] = None + usage_usd: Annotated[BuildUsage | None, Field(alias='usageUsd')] = None + input_schema: Annotated[str | None, Field(alias='inputSchema', examples=['{\\n "title": "Schema for ... }'])] = ( + None + ) + readme: Annotated[str | None, Field(examples=['# Magic Actor\\nThis Actor is magic.'])] = None + build_number: Annotated[str, Field(alias='buildNumber', examples=['0.1.1'])] + actor_definition: Annotated[ActorDefinition | None, Field(alias='actorDefinition')] = None + + +class BuildActorResponse(BaseModel): + data: Build + + +class GetBuildResponse(BaseModel): + data: Build + + +class Id(BaseModel): + type: Annotated[str | None, Field(examples=['string'])] = None + + +class ActId(BaseModel): + type: Annotated[str | None, Field(examples=['string'])] = None + + +class UserId(BaseModel): + type: Annotated[str | None, Field(examples=['string'])] = None + + +class StartedAt(BaseModel): + type: Annotated[str | None, Field(examples=['string'])] = None + format: Annotated[str | None, Field(examples=['date-time'])] = None + example: Annotated[str | None, Field(examples=['2025-01-08T00:00:00.000Z'])] = None + + +class FinishedAt(BaseModel): + type: Annotated[str | None, Field(examples=['string'])] = None + format: Annotated[str | None, Field(examples=['date-time'])] = None + example: Annotated[str | None, Field(examples=['2025-01-08T00:00:00.000Z'])] = None + + +class Status(BaseModel): + type: Annotated[str | None, Field(examples=['string'])] = None + example: Annotated[str | None, Field(examples=['READY'])] = None + + +class Origin(BaseModel): + type: Annotated[str | None, Field(examples=['string'])] = None + example: Annotated[str | None, Field(examples=['API'])] = None + + +class UserAgent(BaseModel): + type: Annotated[str | None, Field(examples=['string'])] = None + + +class Properties1(BaseModel): + origin: Origin | None = None + user_agent: Annotated[UserAgent | None, Field(alias='userAgent')] = None + + +class Meta(BaseModel): + type: Annotated[str | None, Field(examples=['object'])] = None + properties: Properties1 | None = None + + +class Properties(BaseModel): + id: Id | None = None + act_id: Annotated[ActId | None, Field(alias='actId')] = None + user_id: Annotated[UserId | None, Field(alias='userId')] = None + started_at: Annotated[StartedAt | None, Field(alias='startedAt')] = None + finished_at: Annotated[FinishedAt | None, Field(alias='finishedAt')] = None + status: Status | None = None + meta: Meta | None = None + + +class RunsResponseSchemaDataProperties(BaseModel): + type: Annotated[str | None, Field(examples=['object'])] = None + properties: Properties | None = None + + +class Info(BaseModel): + title: Annotated[str | None, Field(examples=['Your Magic Actor'])] = None + version: Annotated[str | None, Field(examples=['1.0'])] = None + x_build_id: Annotated[str | None, Field(alias='x-build-id', examples=['ID of build'])] = None + + +class Server(BaseModel): + url: Annotated[AnyUrl | None, Field(examples=['https://api.apify.com/v2'])] = None + + +class Schema(BaseModel): + field_ref: Annotated[str | None, Field(alias='$ref', examples=['#/components/schemas/inputSchema'])] = None + + +class ApplicationJson(BaseModel): + schema_: Annotated[Schema | None, Field(alias='schema')] = None + + +class Content(BaseModel): + application_json: Annotated[ApplicationJson | None, Field(alias='application/json')] = None + + +class RequestBody(BaseModel): + required: Annotated[bool | None, Field(examples=[True])] = None + content: Content | None = None + + +class Schema1(BaseModel): + type: Annotated[str | None, Field(examples=['string'])] = None + + +class Parameter(BaseModel): + name: Annotated[str | None, Field(examples=['token'])] = None + in_: Annotated[str | None, Field(alias='in', examples=['query'])] = None + required: Annotated[bool | None, Field(examples=[True])] = None + schema_: Annotated[Schema1 | None, Field(alias='schema')] = None + description: Annotated[str | None, Field(examples=['Enter your Apify token here'])] = None + + +class Field200(BaseModel): + description: Annotated[str | None, Field(examples=['OK'])] = None + + +class Responses(BaseModel): + field_200: Annotated[Field200 | None, Field(alias='200')] = None + + +class Post(BaseModel): + operation_id: Annotated[str | None, Field(alias='operationId', examples=['run-sync-get-dataset-items'])] = None + x_openai_is_consequential: Annotated[bool | None, Field(alias='x-openai-isConsequential', examples=[False])] = None + summary: Annotated[ + str | None, + Field( + examples=['Executes an Actor', 'waits for its completion', "and returns Actor's dataset items in response."] + ), + ] = None + tags: Annotated[list[str] | None, Field(examples=[['Run Actor']])] = None + request_body: Annotated[RequestBody | None, Field(alias='requestBody')] = None + parameters: list[Parameter] | None = None + responses: Responses | None = None + + +class FieldActsUsernameActorRunSyncGetDatasetItems(BaseModel): + post: Post | None = None + + +class Schema2(BaseModel): + field_ref: Annotated[str | None, Field(alias='$ref', examples=['#/components/schemas/inputSchema'])] = None + + +class ApplicationJson1(BaseModel): + schema_: Annotated[Schema2 | None, Field(alias='schema')] = None + + +class Content1(BaseModel): + application_json: Annotated[ApplicationJson1 | None, Field(alias='application/json')] = None + + +class RequestBody1(BaseModel): + required: Annotated[bool | None, Field(examples=[True])] = None + content: Content1 | None = None + + +class Schema3(BaseModel): + type: str | None = None + + +class Parameter1(BaseModel): + name: str | None = None + in_: Annotated[str | None, Field(alias='in', examples=['query'])] = None + required: bool | None = None + schema_: Annotated[Schema3 | None, Field(alias='schema')] = None + description: str | None = None + + +class Schema4(BaseModel): + field_ref: Annotated[str | None, Field(alias='$ref', examples=['#/components/schemas/runsResponseSchema'])] = None + + +class ApplicationJson2(BaseModel): + schema_: Annotated[Schema4 | None, Field(alias='schema')] = None + + +class Content2(BaseModel): + application_json: Annotated[ApplicationJson2 | None, Field(alias='application/json')] = None + + +class Field2001(BaseModel): + description: Annotated[str | None, Field(examples=['OK'])] = None + content: Content2 | None = None + + +class Responses1(BaseModel): + field_200: Annotated[Field2001 | None, Field(alias='200')] = None + + +class Post1(BaseModel): + operation_id: Annotated[str | None, Field(alias='operationId', examples=['runs'])] = None + x_openai_is_consequential: Annotated[bool | None, Field(alias='x-openai-isConsequential', examples=[False])] = None + summary: Annotated[ + str | None, Field(examples=['Executes an Actor and returns information about the initiated run in response.']) + ] = None + tags: Annotated[list[str] | None, Field(examples=[['Run Actor']])] = None + request_body: Annotated[RequestBody1 | None, Field(alias='requestBody')] = None + parameters: list[Parameter1] | None = None + responses: Responses1 | None = None + + +class FieldActsUsernameActorRuns(BaseModel): + post: Post1 | None = None + + +class Schema5(BaseModel): + field_ref: Annotated[str | None, Field(alias='$ref', examples=['#/components/schemas/inputSchema'])] = None + + +class ApplicationJson3(BaseModel): + schema_: Annotated[Schema5 | None, Field(alias='schema')] = None + + +class Content3(BaseModel): + application_json: Annotated[ApplicationJson3 | None, Field(alias='application/json')] = None + + +class RequestBody2(BaseModel): + required: Annotated[bool | None, Field(examples=[True])] = None + content: Content3 | None = None + + +class Schema6(BaseModel): + type: str | None = None + + +class Parameter2(BaseModel): + name: str | None = None + in_: Annotated[str | None, Field(alias='in', examples=['query'])] = None + required: bool | None = None + schema_: Annotated[Schema6 | None, Field(alias='schema')] = None + description: str | None = None + + +class Field2002(BaseModel): + description: Annotated[str | None, Field(examples=['OK'])] = None + + +class Responses2(BaseModel): + field_200: Annotated[Field2002 | None, Field(alias='200')] = None + + +class Post2(BaseModel): + operation_id: Annotated[str | None, Field(alias='operationId', examples=['run-sync'])] = None + x_openai_is_consequential: Annotated[bool | None, Field(alias='x-openai-isConsequential', examples=[False])] = None + summary: Annotated[ + str | None, + Field( + examples=[ + 'Executes an Actor', + 'waits for completion', + 'and returns the OUTPUT from Key-value store in response.', + ] + ), + ] = None + tags: Annotated[list[str] | None, Field(examples=[['Run Actor']])] = None + request_body: Annotated[RequestBody2 | None, Field(alias='requestBody')] = None + parameters: list[Parameter2] | None = None + responses: Responses2 | None = None + + +class FieldActsUsernameActorRunSync(BaseModel): + post: Post2 | None = None + + +class Paths(BaseModel): + field_acts__username___actor__run_sync_get_dataset_items: Annotated[ + FieldActsUsernameActorRunSyncGetDatasetItems | None, + Field(alias='/acts/~/run-sync-get-dataset-items'), + ] = None + field_acts__username___actor__runs: Annotated[ + FieldActsUsernameActorRuns | None, Field(alias='/acts/~/runs') + ] = None + field_acts__username___actor__run_sync: Annotated[ + FieldActsUsernameActorRunSync | None, Field(alias='/acts/~/run-sync') + ] = None + + +class InputSchema(BaseModel): + type: Annotated[str | None, Field(examples=['object'])] = None + + +class Properties2(BaseModel): + data: RunsResponseSchemaDataProperties | None = None + + +class RunsResponseSchema(BaseModel): + type: Annotated[str | None, Field(examples=['object'])] = None + properties: Properties2 | None = None + + +class Schemas(BaseModel): + input_schema: Annotated[InputSchema | None, Field(alias='inputSchema')] = None + runs_response_schema: Annotated[RunsResponseSchema | None, Field(alias='runsResponseSchema')] = None + + +class Components(BaseModel): + schemas: Schemas | None = None + + +class GetOpenApiResponse(BaseModel): + openapi: Annotated[str | None, Field(examples=['3.0.1'])] = None + info: Info | None = None + servers: list[Server] | None = None + paths: Paths | None = None + components: Components | None = None + + +class PostAbortBuildResponse(BaseModel): + data: Build + + +class RunOrigin(Enum): + DEVELOPMENT = 'DEVELOPMENT' + WEB = 'WEB' + API = 'API' + SCHEDULER = 'SCHEDULER' + TEST = 'TEST' + WEBHOOK = 'WEBHOOK' + ACTOR = 'ACTOR' + CLI = 'CLI' + STANDBY = 'STANDBY' + + +class RunMeta(BaseModel): + origin: RunOrigin + + +class RunShort(BaseModel): + id: Annotated[str, Field(examples=['HG7ML7M8z78YcAPEB'])] + act_id: Annotated[str, Field(alias='actId', examples=['HDSasDasz78YcAPEB'])] + actor_task_id: Annotated[str | None, Field(alias='actorTaskId', examples=['KJHSKHausidyaJKHs'])] = None + status: ActorJobStatus + started_at: Annotated[AwareDatetime, Field(alias='startedAt', examples=['2019-11-30T07:34:24.202Z'])] + finished_at: Annotated[AwareDatetime, Field(alias='finishedAt', examples=['2019-12-12T09:30:12.202Z'])] + build_id: Annotated[str, Field(alias='buildId', examples=['HG7ML7M8z78YcAPEB'])] + build_number: Annotated[str | None, Field(alias='buildNumber', examples=['0.0.2'])] = None + meta: RunMeta + usage_total_usd: Annotated[float, Field(alias='usageTotalUsd', examples=[0.2])] + default_key_value_store_id: Annotated[str, Field(alias='defaultKeyValueStoreId', examples=['sfAjeR4QmeJCQzTfe'])] + default_dataset_id: Annotated[str, Field(alias='defaultDatasetId', examples=['3ZojQDdFTsyE7Moy4'])] + default_request_queue_id: Annotated[str, Field(alias='defaultRequestQueueId', examples=['so93g2shcDzK3pA85'])] + + +class ListOfRuns(PaginationResponse): + items: list[RunShort] + + +class GetListOfRunsResponse(BaseModel): + data: ListOfRuns + + +class RunStats(BaseModel): + input_body_len: Annotated[int | None, Field(alias='inputBodyLen', examples=[240], ge=0)] = None + migration_count: Annotated[int | None, Field(alias='migrationCount', examples=[0], ge=0)] = None + reboot_count: Annotated[int | None, Field(alias='rebootCount', examples=[0], ge=0)] = None + restart_count: Annotated[int, Field(alias='restartCount', examples=[0], ge=0)] + resurrect_count: Annotated[int, Field(alias='resurrectCount', examples=[2], ge=0)] + mem_avg_bytes: Annotated[float | None, Field(alias='memAvgBytes', examples=[267874071.9])] = None + mem_max_bytes: Annotated[int | None, Field(alias='memMaxBytes', examples=[404713472], ge=0)] = None + mem_current_bytes: Annotated[int | None, Field(alias='memCurrentBytes', examples=[0], ge=0)] = None + cpu_avg_usage: Annotated[float | None, Field(alias='cpuAvgUsage', examples=[33.7532101107538])] = None + cpu_max_usage: Annotated[float | None, Field(alias='cpuMaxUsage', examples=[169.650735534941])] = None + cpu_current_usage: Annotated[float | None, Field(alias='cpuCurrentUsage', examples=[0])] = None + net_rx_bytes: Annotated[int | None, Field(alias='netRxBytes', examples=[103508042], ge=0)] = None + net_tx_bytes: Annotated[int | None, Field(alias='netTxBytes', examples=[4854600], ge=0)] = None + duration_millis: Annotated[int | None, Field(alias='durationMillis', examples=[248472], ge=0)] = None + run_time_secs: Annotated[float | None, Field(alias='runTimeSecs', examples=[248.472], ge=0.0)] = None + metamorph: Annotated[int | None, Field(examples=[0], ge=0)] = None + compute_units: Annotated[float, Field(alias='computeUnits', examples=[0.13804], ge=0.0)] + + +class RunOptions(BaseModel): + build: Annotated[str, Field(examples=['latest'])] + timeout_secs: Annotated[int, Field(alias='timeoutSecs', examples=[300], ge=0)] + memory_mbytes: Annotated[int, Field(alias='memoryMbytes', examples=[1024], ge=128, le=32768)] + disk_mbytes: Annotated[int, Field(alias='diskMbytes', examples=[2048], ge=0)] + max_items: Annotated[int | None, Field(alias='maxItems', examples=[1000], ge=1)] = None + max_total_charge_usd: Annotated[float | None, Field(alias='maxTotalChargeUsd', examples=[5], ge=0.0)] = None + + +class GeneralAccessEnum(Enum): + """Defines the general access level for the resource.""" + + RESTRICTED = 'RESTRICTED' + ANYONE_WITH_ID_CAN_READ = 'ANYONE_WITH_ID_CAN_READ' + FOLLOW_USER_SETTING = 'FOLLOW_USER_SETTING' + + +class RunUsage(BaseModel): + actor_compute_units: Annotated[float | None, Field(alias='ACTOR_COMPUTE_UNITS', examples=[3])] = None + dataset_reads: Annotated[int | None, Field(alias='DATASET_READS', examples=[4])] = None + dataset_writes: Annotated[int | None, Field(alias='DATASET_WRITES', examples=[4])] = None + key_value_store_reads: Annotated[int | None, Field(alias='KEY_VALUE_STORE_READS', examples=[5])] = None + key_value_store_writes: Annotated[int | None, Field(alias='KEY_VALUE_STORE_WRITES', examples=[3])] = None + key_value_store_lists: Annotated[int | None, Field(alias='KEY_VALUE_STORE_LISTS', examples=[5])] = None + request_queue_reads: Annotated[int | None, Field(alias='REQUEST_QUEUE_READS', examples=[2])] = None + request_queue_writes: Annotated[int | None, Field(alias='REQUEST_QUEUE_WRITES', examples=[1])] = None + data_transfer_internal_gbytes: Annotated[ + float | None, Field(alias='DATA_TRANSFER_INTERNAL_GBYTES', examples=[1]) + ] = None + data_transfer_external_gbytes_: Annotated[ + float | None, Field(alias='DATA_TRANSFER_EXTERNAL_GBYTES?', examples=[3]) + ] = None + proxy_residential_transfer_gbytes: Annotated[ + float | None, Field(alias='PROXY_RESIDENTIAL_TRANSFER_GBYTES', examples=[34]) + ] = None + proxy_serps: Annotated[int | None, Field(alias='PROXY_SERPS', examples=[3])] = None + + +class RunUsageUsd(BaseModel): + """Resource usage costs in USD. All values are monetary amounts in US dollars.""" + + actor_compute_units: Annotated[float | None, Field(alias='ACTOR_COMPUTE_UNITS', examples=[0.0003])] = None + dataset_reads: Annotated[float | None, Field(alias='DATASET_READS', examples=[0.0001])] = None + dataset_writes: Annotated[float | None, Field(alias='DATASET_WRITES', examples=[0.0001])] = None + key_value_store_reads: Annotated[float | None, Field(alias='KEY_VALUE_STORE_READS', examples=[0.0001])] = None + key_value_store_writes: Annotated[float | None, Field(alias='KEY_VALUE_STORE_WRITES', examples=[5e-05])] = None + key_value_store_lists: Annotated[float | None, Field(alias='KEY_VALUE_STORE_LISTS', examples=[0.0001])] = None + request_queue_reads: Annotated[float | None, Field(alias='REQUEST_QUEUE_READS', examples=[0.0001])] = None + request_queue_writes: Annotated[float | None, Field(alias='REQUEST_QUEUE_WRITES', examples=[0.0001])] = None + data_transfer_internal_gbytes: Annotated[ + float | None, Field(alias='DATA_TRANSFER_INTERNAL_GBYTES', examples=[0.001]) + ] = None + data_transfer_external_gbytes_: Annotated[ + float | None, Field(alias='DATA_TRANSFER_EXTERNAL_GBYTES?', examples=[0.003]) + ] = None + proxy_residential_transfer_gbytes: Annotated[ + float | None, Field(alias='PROXY_RESIDENTIAL_TRANSFER_GBYTES', examples=[0.034]) + ] = None + proxy_serps: Annotated[float | None, Field(alias='PROXY_SERPS', examples=[0.003])] = None + + +class Run(BaseModel): + """Represents an Actor run and its associated data.""" + + id: Annotated[str, Field(examples=['HG7ML7M8z78YcAPEB'])] + """ + Unique identifier of the Actor run. + """ + act_id: Annotated[str, Field(alias='actId', examples=['HDSasDasz78YcAPEB'])] + """ + ID of the Actor that was run. + """ + user_id: Annotated[str, Field(alias='userId', examples=['7sT5jcggjjA9fNcxF'])] + """ + ID of the user who started the run. + """ + actor_task_id: Annotated[str | None, Field(alias='actorTaskId', examples=['KJHSKHausidyaJKHs'])] = None + """ + ID of the Actor task, if the run was started from a task. + """ + started_at: Annotated[AwareDatetime, Field(alias='startedAt', examples=['2019-11-30T07:34:24.202Z'])] + """ + Time when the Actor run started. + """ + finished_at: Annotated[AwareDatetime | None, Field(alias='finishedAt', examples=['2019-12-12T09:30:12.202Z'])] = ( + None + ) + """ + Time when the Actor run finished. + """ + status: ActorJobStatus + """ + Current status of the Actor run. + """ + status_message: Annotated[str | None, Field(alias='statusMessage', examples=['Actor is running'])] = None + """ + Detailed message about the run status. + """ + is_status_message_terminal: Annotated[bool | None, Field(alias='isStatusMessageTerminal', examples=[False])] = None + """ + Whether the status message is terminal (final). + """ + meta: RunMeta + """ + Metadata about the Actor run. + """ + pricing_info: Annotated[ + PayPerEventActorPricingInfo + | PricePerDatasetItemActorPricingInfo + | FlatPricePerMonthActorPricingInfo + | FreeActorPricingInfo + | None, + Field(alias='pricingInfo', discriminator='pricing_model', title='ActorRunPricingInfo'), + ] = None + """ + Pricing information for the Actor. + """ + stats: RunStats + """ + Statistics of the Actor run. + """ + charged_event_counts: Annotated[ + dict[str, int] | None, + Field(alias='chargedEventCounts', examples=[{'actor-start': 1, 'page-crawled': 150, 'data-extracted': 75}]), + ] = None + """ + A map of charged event types to their counts. The keys are event type identifiers defined by the Actor's pricing model (pay-per-event), and the values are the number of times each event was charged during this run. + """ + options: RunOptions + """ + Configuration options for the Actor run. + """ + build_id: Annotated[str, Field(alias='buildId', examples=['7sT5jcggjjA9fNcxF'])] + """ + ID of the Actor build used for this run. + """ + exit_code: Annotated[int | None, Field(alias='exitCode', examples=[0])] = None + """ + Exit code of the Actor run process. + """ + general_access: Annotated[GeneralAccessEnum, Field(alias='generalAccess')] + """ + General access level for the Actor run. + """ + default_key_value_store_id: Annotated[str, Field(alias='defaultKeyValueStoreId', examples=['eJNzqsbPiopwJcgGQ'])] + """ + ID of the default key-value store for this run. + """ + default_dataset_id: Annotated[str, Field(alias='defaultDatasetId', examples=['wmKPijuyDnPZAPRMk'])] + """ + ID of the default dataset for this run. + """ + default_request_queue_id: Annotated[str, Field(alias='defaultRequestQueueId', examples=['FL35cSF7jrxr3BY39'])] + """ + ID of the default request queue for this run. + """ + build_number: Annotated[str | None, Field(alias='buildNumber', examples=['0.0.36'])] = None + """ + Build number of the Actor build used for this run. + """ + container_url: Annotated[ + AnyUrl | None, Field(alias='containerUrl', examples=['https://g8kd8kbc5ge8.runs.apify.net']) + ] = None + """ + URL of the container running the Actor. + """ + is_container_server_ready: Annotated[bool | None, Field(alias='isContainerServerReady', examples=[True])] = None + """ + Whether the container's HTTP server is ready to accept requests. + """ + git_branch_name: Annotated[str | None, Field(alias='gitBranchName', examples=['master'])] = None + """ + Name of the git branch used for the Actor build. + """ + usage: RunUsage | None = None + """ + Resource usage statistics for the run. + """ + usage_total_usd: Annotated[float | None, Field(alias='usageTotalUsd', examples=[0.2654])] = None + """ + Total cost of the run in USD. + """ + usage_usd: Annotated[RunUsageUsd | None, Field(alias='usageUsd')] = None + """ + Resource usage costs in USD. + """ + + +class GetRunResponse(BaseModel): + data: Run + + +class TaskStats(BaseModel): + total_runs: Annotated[int | None, Field(alias='totalRuns', examples=[15])] = None + + +class TaskShort(BaseModel): + id: Annotated[str, Field(examples=['zdc3Pyhyz3m8vjDeM'])] + user_id: Annotated[str, Field(alias='userId', examples=['wRsJZtadYvn4mBZmm'])] + act_id: Annotated[str, Field(alias='actId', examples=['asADASadYvn4mBZmm'])] + act_name: Annotated[str | None, Field(alias='actName', examples=['my-actor'])] = None + name: Annotated[str, Field(examples=['my-task'])] + username: Annotated[str | None, Field(examples=['janedoe'])] = None + act_username: Annotated[str | None, Field(alias='actUsername', examples=['janedoe'])] = None + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2018-10-26T07:23:14.855Z'])] + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2018-10-26T13:30:49.578Z'])] + stats: TaskStats | None = None + + +class ListOfTasks(PaginationResponse): + items: list[TaskShort] + + +class GetListOfTasksResponse(BaseModel): + data: ListOfTasks + + +class TaskOptions(BaseModel): + build: Annotated[str | None, Field(examples=['latest'])] = None + timeout_secs: Annotated[int | None, Field(alias='timeoutSecs', examples=[300])] = None + memory_mbytes: Annotated[int | None, Field(alias='memoryMbytes', examples=[128])] = None + restart_on_error: Annotated[bool | None, Field(alias='restartOnError', examples=[False])] = None + + +class TaskInput(BaseModel): + hello: Annotated[str | None, Field(examples=['world'])] = None + + +class CreateTaskRequest(BaseModel): + act_id: Annotated[str, Field(alias='actId', examples=['asADASadYvn4mBZmm'])] + name: Annotated[str, Field(examples=['my-task'])] + options: TaskOptions | None = None + input: TaskInput | None = None + + +class Task(BaseModel): + id: Annotated[str, Field(examples=['zdc3Pyhyz3m8vjDeM'])] + user_id: Annotated[str, Field(alias='userId', examples=['wRsJZtadYvn4mBZmm'])] + act_id: Annotated[str, Field(alias='actId', examples=['asADASadYvn4mBZmm'])] + name: Annotated[str, Field(examples=['my-task'])] + username: Annotated[str | None, Field(examples=['janedoe'])] = None + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2018-10-26T07:23:14.855Z'])] + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2018-10-26T13:30:49.578Z'])] + removed_at: Annotated[AwareDatetime | None, Field(alias='removedAt')] = None + stats: TaskStats | None = None + options: TaskOptions | None = None + input: TaskInput | None = None + standby_url: Annotated[AnyUrl | None, Field(alias='standbyUrl')] = None + + +class CreateTaskResponse(BaseModel): + data: Task + + +class UpdateTaskRequest(BaseModel): + id: Annotated[str, Field(examples=['ZxLNxrRaZrSjuhT9y'])] + user_id: Annotated[str, Field(alias='userId', examples=['BPWZBd7Z9c746JAnF'])] + act_id: Annotated[str, Field(alias='actId', examples=['asADASadYvn4mBZmm'])] + name: Annotated[str, Field(examples=['my-task'])] + username: Annotated[str | None, Field(examples=['janedoe'])] = None + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2018-10-26T07:23:14.855Z'])] + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2018-10-26T13:30:49.578Z'])] + removed_at: Annotated[AwareDatetime | None, Field(alias='removedAt')] = None + stats: TaskStats | None = None + options: TaskOptions | None = None + input: TaskInput | None = None + + +class Webhook(BaseModel): + id: Annotated[str, Field(examples=['YiKoxjkaS9gjGTqhF'])] + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2019-12-12T07:34:14.202Z'])] + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2019-12-13T08:36:13.202Z'])] + user_id: Annotated[str, Field(alias='userId', examples=['wRsJZtadYvn4mBZmm'])] + is_ad_hoc: Annotated[bool | None, Field(alias='isAdHoc', examples=[False])] = None + should_interpolate_strings: Annotated[bool | None, Field(alias='shouldInterpolateStrings', examples=[False])] = None + event_types: Annotated[list[WebhookEventType], Field(alias='eventTypes', examples=[['ACTOR.RUN.SUCCEEDED']])] + condition: WebhookCondition + ignore_ssl_errors: Annotated[bool, Field(alias='ignoreSslErrors', examples=[False])] + do_not_retry: Annotated[bool | None, Field(alias='doNotRetry', examples=[False])] = None + request_url: Annotated[AnyUrl, Field(alias='requestUrl', examples=['http://example.com/'])] + payload_template: Annotated[ + str | None, Field(alias='payloadTemplate', examples=['{\\n "userId": {{userId}}...']) + ] = None + headers_template: Annotated[ + str | None, Field(alias='headersTemplate', examples=['{\\n "Authorization": "Bearer ..."}']) + ] = None + description: Annotated[str | None, Field(examples=['this is webhook description'])] = None + last_dispatch: Annotated[ExampleWebhookDispatch | None, Field(alias='lastDispatch')] = None + stats: WebhookStats | None = None + + +class UpdateRunRequest(BaseModel): + run_id: Annotated[str | None, Field(alias='runId', examples=['3KH8gEpp4d8uQSe8T'])] = None + status_message: Annotated[str | None, Field(alias='statusMessage', examples=['Actor has finished'])] = None + is_status_message_terminal: Annotated[bool | None, Field(alias='isStatusMessageTerminal', examples=[True])] = None + general_access: Annotated[GeneralAccessEnum | None, Field(alias='generalAccess')] = None + + +class ChargeRunRequest(BaseModel): + event_name: Annotated[str, Field(alias='eventName', examples=['ANALYZE_PAGE'])] + count: Annotated[int, Field(examples=[1])] + + +class KeyValueStoreStats(BaseModel): + read_count: Annotated[int, Field(alias='readCount', examples=[9])] + write_count: Annotated[int, Field(alias='writeCount', examples=[3])] + delete_count: Annotated[int, Field(alias='deleteCount', examples=[6])] + list_count: Annotated[int, Field(alias='listCount', examples=[2])] + s3_storage_bytes: Annotated[int | None, Field(alias='s3StorageBytes', examples=[18])] = None + + +class KeyValueStore(BaseModel): + id: Annotated[str, Field(examples=['WkzbQMuFYuamGv3YF'])] + name: Annotated[str | None, Field(examples=['d7b9MDYsbtX5L7XAj'])] = None + user_id: Annotated[str | None, Field(alias='userId', examples=['BPWDBd7Z9c746JAnF'])] = None + username: Annotated[str | None, Field(examples=['janedoe'])] = None + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2019-12-12T07:34:14.202Z'])] + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2019-12-13T08:36:13.202Z'])] + accessed_at: Annotated[AwareDatetime, Field(alias='accessedAt', examples=['2019-12-14T08:36:13.202Z'])] + act_id: Annotated[str | None, Field(alias='actId', examples=[None])] = None + act_run_id: Annotated[str | None, Field(alias='actRunId', examples=[None])] = None + console_url: Annotated[ + AnyUrl | None, + Field(alias='consoleUrl', examples=['https://console.apify.com/storage/key-value-stores/27TmTznX9YPeAYhkC']), + ] = None + keys_public_url: Annotated[ + AnyUrl | None, + Field( + alias='keysPublicUrl', + examples=['https://api.apify.com/v2/key-value-stores/WkzbQMuFYuamGv3YF/keys?signature=abc123'], + ), + ] = None + """ + A public link to access keys of the key-value store directly. + """ + url_signing_secret_key: Annotated[str | None, Field(alias='urlSigningSecretKey')] = None + """ + A secret key for generating signed public URLs. It is only provided to clients with WRITE permission for the key-value store. + """ + general_access: Annotated[GeneralAccessEnum | None, Field(alias='generalAccess')] = None + stats: KeyValueStoreStats | None = None + + +class ListOfKeyValueStores(PaginationResponse): + items: list[KeyValueStore] + + +class GetListOfKeyValueStoresResponse(BaseModel): + data: ListOfKeyValueStores + + +class CreateKeyValueStoreResponse(BaseModel): + data: KeyValueStore + + +class GetKeyValueStoreResponse(BaseModel): + data: KeyValueStore + + +class UpdateStoreRequest(BaseModel): + name: str | None = None + general_access: Annotated[GeneralAccessEnum | None, Field(alias='generalAccess')] = None + + +class UpdateKeyValueStoreResponse(BaseModel): + data: KeyValueStore + + +class KeyValueStoreKey(BaseModel): + key: Annotated[str, Field(examples=['second-key'])] + size: Annotated[int, Field(examples=[36])] + record_public_url: Annotated[ + AnyUrl, + Field( + alias='recordPublicUrl', + examples=['https://api.apify.com/v2/key-value-stores/WkzbQMuFYuamGv3YF/records/some-key?signature=abc123'], + ), + ] + """ + A public link to access this record directly. + """ + + +class ListOfKeys(BaseModel): + items: list[KeyValueStoreKey] + count: Annotated[int, Field(examples=[2])] + limit: Annotated[int, Field(examples=[2])] + exclusive_start_key: Annotated[str | None, Field(alias='exclusiveStartKey', examples=['some-key'])] = None + is_truncated: Annotated[bool, Field(alias='isTruncated', examples=[True])] + next_exclusive_start_key: Annotated[str | None, Field(alias='nextExclusiveStartKey', examples=['third-key'])] = None + + +class GetListOfKeysResponse(BaseModel): + data: ListOfKeys + + +class GetRecordResponse(BaseModel): + foo: str + + +class PutRecordRequest(BaseModel): + foo: Annotated[str | None, Field(examples=['bar'])] = None + + +class DatasetListItem(BaseModel): + id: Annotated[str, Field(examples=['WkzbQMuFYuamGv3YF'])] + name: Annotated[str, Field(examples=['d7b9MDYsbtX5L7XAj'])] + user_id: Annotated[str, Field(alias='userId', examples=['tbXmWu7GCxnyYtSiL'])] + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2019-12-12T07:34:14.202Z'])] + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2019-12-13T08:36:13.202Z'])] + accessed_at: Annotated[AwareDatetime, Field(alias='accessedAt', examples=['2019-12-14T08:36:13.202Z'])] + item_count: Annotated[int, Field(alias='itemCount', examples=[7])] + clean_item_count: Annotated[int, Field(alias='cleanItemCount', examples=[5])] + act_id: Annotated[str | None, Field(alias='actId')] = None + act_run_id: Annotated[str | None, Field(alias='actRunId')] = None + + +class ListOfDatasets(PaginationResponse): + items: list[DatasetListItem] + + +class GetListOfDatasetsResponse(BaseModel): + data: ListOfDatasets + + +class DatasetStats(BaseModel): + read_count: Annotated[int, Field(alias='readCount', examples=[22])] + write_count: Annotated[int, Field(alias='writeCount', examples=[3])] + storage_bytes: Annotated[int, Field(alias='storageBytes', examples=[783])] + + +class Dataset(BaseModel): + id: Annotated[str, Field(examples=['WkzbQMuFYuamGv3YF'])] + name: Annotated[str | None, Field(examples=['d7b9MDYsbtX5L7XAj'])] = None + user_id: Annotated[str, Field(alias='userId', examples=['wRsJZtadYvn4mBZmm'])] + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2019-12-12T07:34:14.202Z'])] + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2019-12-13T08:36:13.202Z'])] + accessed_at: Annotated[AwareDatetime, Field(alias='accessedAt', examples=['2019-12-14T08:36:13.202Z'])] + item_count: Annotated[int, Field(alias='itemCount', examples=[7], ge=0)] + clean_item_count: Annotated[int, Field(alias='cleanItemCount', examples=[5], ge=0)] + act_id: Annotated[str | None, Field(alias='actId')] = None + act_run_id: Annotated[str | None, Field(alias='actRunId')] = None + fields: list[str] | None = None + schema_: Annotated[ + dict[str, Any] | None, + Field( + alias='schema', + examples=[ + { + 'actorSpecification': 1, + 'title': 'My dataset', + 'views': { + 'overview': { + 'title': 'Overview', + 'transformation': {'fields': ['linkUrl']}, + 'display': { + 'component': 'table', + 'properties': {'linkUrl': {'label': 'Link URL', 'format': 'link'}}, + }, + } + }, + } + ], + ), + ] = None + """ + Defines the schema of items in your dataset, the full specification can be found in [Apify docs](/platform/actors/development/actor-definition/dataset-schema) + """ + console_url: Annotated[ + AnyUrl, Field(alias='consoleUrl', examples=['https://console.apify.com/storage/datasets/27TmTznX9YPeAYhkC']) + ] + items_public_url: Annotated[ + AnyUrl | None, + Field( + alias='itemsPublicUrl', + examples=['https://api.apify.com/v2/datasets/WkzbQMuFYuamGv3YF/items?signature=abc123'], + ), + ] = None + """ + A public link to access the dataset items directly. + """ + url_signing_secret_key: Annotated[str | None, Field(alias='urlSigningSecretKey')] = None + """ + A secret key for generating signed public URLs. It is only provided to clients with WRITE permission for the dataset. + """ + general_access: Annotated[GeneralAccessEnum | None, Field(alias='generalAccess')] = None + stats: DatasetStats | None = None + + +class CreateDatasetResponse(BaseModel): + data: Dataset + + +class UpdateDatasetRequest(BaseModel): + name: str | None = None + general_access: Annotated[GeneralAccessEnum | None, Field(alias='generalAccess')] = None + + +class PutItemsRequest(BaseModel): + foo: str + + +class ValidationError(BaseModel): + instance_path: Annotated[str | None, Field(alias='instancePath')] = None + """ + The path to the instance being validated. + """ + schema_path: Annotated[str | None, Field(alias='schemaPath')] = None + """ + The path to the schema that failed the validation. + """ + keyword: str | None = None + """ + The validation keyword that caused the error. + """ + message: str | None = None + """ + A message describing the validation error. + """ + params: dict[str, Any] | None = None + """ + Additional parameters specific to the validation error. + """ + + +class InvalidItem(BaseModel): + item_position: Annotated[int | None, Field(alias='itemPosition', examples=[2])] = None + """ + The position of the invalid item in the array. + """ + validation_errors: Annotated[list[ValidationError] | None, Field(alias='validationErrors')] = None + """ + A complete list of AJV validation error objects for the invalid item. + """ + + +class SchemaValidationErrorData(BaseModel): + invalid_items: Annotated[list[InvalidItem], Field(alias='invalidItems')] + """ + A list of invalid items in the received array of items. + """ + + +class DatasetSchemaValidationErrorDetails(BaseModel): + type: Annotated[str, Field(examples=['schema-validation-error'])] + """ + The type of the error. + """ + message: Annotated[str, Field(examples=['Schema validation failed'])] + """ + A human-readable message describing the error. + """ + data: SchemaValidationErrorData + + +class DatasetSchemaValidationError(BaseModel): + error: DatasetSchemaValidationErrorDetails | None = None + + +class PutItemResponseError(BaseModel): + error: DatasetSchemaValidationError + + +class DatasetFieldStatistics(BaseModel): + min: float | None = None + """ + Minimum value of the field. For numbers, this is calculated directly. For strings, this is the length of the shortest string. For arrays, this is the length of the shortest array. For objects, this is the number of keys in the smallest object. + """ + max: float | None = None + """ + Maximum value of the field. For numbers, this is calculated directly. For strings, this is the length of the longest string. For arrays, this is the length of the longest array. For objects, this is the number of keys in the largest object. + """ + null_count: Annotated[int | None, Field(alias='nullCount')] = None + """ + How many items in the dataset have a null value for this field. + """ + empty_count: Annotated[int | None, Field(alias='emptyCount')] = None + """ + How many items in the dataset are `undefined`, meaning that for example empty string is not considered empty. + """ + + +class DatasetStatistics(BaseModel): + field_statistics: Annotated[dict[str, Any] | None, Field(alias='fieldStatistics')] = None + """ + When you configure the dataset [fields schema](https://docs.apify.com/platform/actors/development/actor-definition/dataset-schema/validation), we measure the statistics such as `min`, `max`, `nullCount` and `emptyCount` for each field. This property provides statistics for each field from dataset fields schema.

See dataset field statistics [documentation](https://docs.apify.com/platform/actors/development/actor-definition/dataset-schema/validation#dataset-field-statistics) for more information. + """ + + +class GetDatasetStatisticsResponse(BaseModel): + data: DatasetStatistics + + +class RequestQueueShort(BaseModel): + """A shortened request queue object for list responses.""" + + id: Annotated[str, Field(examples=['WkzbQMuFYuamGv3YF'])] + """ + A unique identifier assigned to the request queue. + """ + name: Annotated[str, Field(examples=['some-name'])] + """ + The name of the request queue. + """ + user_id: Annotated[str, Field(alias='userId', examples=['wRsJZtadYvn4mBZmm'])] + """ + The ID of the user who owns the request queue. + """ + username: Annotated[str, Field(examples=['janedoe'])] + """ + The username of the user who owns the request queue. + """ + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2019-12-12T07:34:14.202Z'])] + """ + The timestamp when the request queue was created. + """ + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2019-12-13T08:36:13.202Z'])] + """ + The timestamp when the request queue was last modified. + """ + accessed_at: Annotated[AwareDatetime, Field(alias='accessedAt', examples=['2019-12-14T08:36:13.202Z'])] + """ + The timestamp when the request queue was last accessed. + """ + expire_at: Annotated[AwareDatetime | None, Field(alias='expireAt', examples=['2019-06-02T17:15:06.751Z'])] = None + """ + The timestamp when the request queue will expire and be deleted. + """ + total_request_count: Annotated[int, Field(alias='totalRequestCount', examples=[100])] + """ + The total number of requests in the request queue. + """ + handled_request_count: Annotated[int, Field(alias='handledRequestCount', examples=[50])] + """ + The number of requests that have been handled. + """ + pending_request_count: Annotated[int, Field(alias='pendingRequestCount', examples=[50])] + """ + The number of requests that are pending and have not been handled yet. + """ + act_id: Annotated[str | None, Field(alias='actId')] = None + """ + The ID of the Actor that created this request queue. + """ + act_run_id: Annotated[str | None, Field(alias='actRunId')] = None + """ + The ID of the Actor run that created this request queue. + """ + had_multiple_clients: Annotated[bool, Field(alias='hadMultipleClients', examples=[True])] + """ + Whether the request queue has been accessed by multiple different clients. + """ + + +class ListOfRequestQueues(PaginationResponse): + """A paginated list of request queues.""" + + items: list[RequestQueueShort] + """ + The array of request queues. + """ + + +class GetListOfRequestQueuesResponse(BaseModel): + """Response containing a list of request queues.""" + + data: ListOfRequestQueues + + +class RequestQueueStats(BaseModel): + """Statistics about request queue operations and storage.""" + + delete_count: Annotated[int | None, Field(alias='deleteCount', examples=[0])] = None + """ + The number of delete operations performed on the request queue. + """ + head_item_read_count: Annotated[int | None, Field(alias='headItemReadCount', examples=[5])] = None + """ + The number of times requests from the head were read. + """ + read_count: Annotated[int | None, Field(alias='readCount', examples=[100])] = None + """ + The total number of read operations performed on the request queue. + """ + storage_bytes: Annotated[int | None, Field(alias='storageBytes', examples=[1024])] = None + """ + The total storage size in bytes used by the request queue. + """ + write_count: Annotated[int | None, Field(alias='writeCount', examples=[10])] = None + """ + The total number of write operations performed on the request queue. + """ + + +class RequestQueue(BaseModel): + """A request queue object containing metadata and statistics.""" + + id: Annotated[str, Field(examples=['WkzbQMuFYuamGv3YF'])] + """ + A unique identifier assigned to the request queue. + """ + name: Annotated[str | None, Field(examples=['some-name'])] = None + """ + The name of the request queue. + """ + user_id: Annotated[str, Field(alias='userId', examples=['wRsJZtadYvn4mBZmm'])] + """ + The ID of the user who owns the request queue. + """ + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2019-12-12T07:34:14.202Z'])] + """ + The timestamp when the request queue was created. + """ + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2030-12-13T08:36:13.202Z'])] + """ + The timestamp when the request queue was last modified. Modifications include adding, updating, or removing requests, as well as locking or unlocking requests in the request queue. + """ + accessed_at: Annotated[AwareDatetime, Field(alias='accessedAt', examples=['2019-12-14T08:36:13.202Z'])] + """ + The timestamp when the request queue was last accessed. + """ + total_request_count: Annotated[int, Field(alias='totalRequestCount', examples=[870], ge=0)] + """ + The total number of requests in the request queue. + """ + handled_request_count: Annotated[int, Field(alias='handledRequestCount', examples=[100], ge=0)] + """ + The number of requests that have been handled. + """ + pending_request_count: Annotated[int, Field(alias='pendingRequestCount', examples=[670], ge=0)] + """ + The number of requests that are pending and have not been handled yet. + """ + had_multiple_clients: Annotated[bool, Field(alias='hadMultipleClients', examples=[True])] + """ + Whether the request queue has been accessed by multiple different clients. + """ + console_url: Annotated[ + AnyUrl, Field(alias='consoleUrl', examples=['https://api.apify.com/v2/request-queues/27TmTznX9YPeAYhkC']) + ] + """ + The URL to view the request queue in the Apify console. + """ + stats: RequestQueueStats | None = None + general_access: Annotated[GeneralAccessEnum | None, Field(alias='generalAccess')] = None + + +class CreateRequestQueueResponse(BaseModel): + """Response containing the created request queue.""" + + data: RequestQueue + + +class GetRequestQueueResponse(BaseModel): + """Response containing the request queue details.""" + + data: RequestQueue + + +class UpdateRequestQueueRequest(BaseModel): + """Request object for updating a request queue.""" + + name: str | None = None + """ + The new name for the request queue. + """ + general_access: Annotated[GeneralAccessEnum | None, Field(alias='generalAccess')] = None + + +class UpdateRequestQueueResponse(BaseModel): + """Response containing the updated request queue.""" + + data: RequestQueue + + +class RequestDraft(BaseModel): + """A request that failed to be processed during a request queue operation and can be retried.""" + + unique_key: Annotated[str, Field(alias='uniqueKey', examples=['GET|60d83e70|e3b0c442|https://apify.com'])] + """ + A unique key used for request de-duplication. Requests with the same unique key are considered identical. + """ + url: Annotated[AnyUrl, Field(examples=['https://apify.com'])] + """ + The URL of the request. + """ + method: Annotated[str, Field(examples=['GET'])] + """ + The HTTP method of the request. + """ + + +class AddedRequest(BaseModel): + """Information about a request that was successfully added to a request queue.""" + + request_id: Annotated[str, Field(alias='requestId', examples=['sbJ7klsdf7ujN9l'])] + """ + A unique identifier assigned to the request. + """ + unique_key: Annotated[str, Field(alias='uniqueKey', examples=['GET|60d83e70|e3b0c442|https://apify.com'])] + """ + A unique key used for request de-duplication. Requests with the same unique key are considered identical. + """ + was_already_present: Annotated[bool, Field(alias='wasAlreadyPresent', examples=[False])] + """ + Indicates whether a request with the same unique key already existed in the request queue. If true, no new request was created. + """ + was_already_handled: Annotated[bool, Field(alias='wasAlreadyHandled', examples=[False])] + """ + Indicates whether a request with the same unique key has already been processed by the request queue. + """ + + +class BatchAddResult(BaseModel): + """Result of a batch add operation containing successfully processed and failed requests.""" + + processed_requests: Annotated[list[AddedRequest], Field(alias='processedRequests')] + """ + Requests that were successfully added to the request queue. + """ + unprocessed_requests: Annotated[list[RequestDraft], Field(alias='unprocessedRequests')] + """ + Requests that failed to be added and can be retried. + """ + + +class BatchAddResponse(BaseModel): + """Response containing the result of a batch add operation.""" + + data: BatchAddResult + + +class DeletedRequest(BaseModel): + """Confirmation of a request that was successfully deleted from a request queue.""" + + unique_key: Annotated[str, Field(alias='uniqueKey', examples=['GET|60d83e70|e3b0c442|https://apify.com'])] + """ + A unique key used for request de-duplication. Requests with the same unique key are considered identical. + """ + id: Annotated[str | None, Field(examples=['sbJ7klsdf7ujN9l'])] = None + """ + A unique identifier assigned to the request. + """ + + +class BatchDeleteResult(BaseModel): + """Result of a batch delete operation containing successfully deleted and failed requests.""" + + processed_requests: Annotated[list[DeletedRequest], Field(alias='processedRequests')] + """ + Requests that were successfully deleted from the request queue. + """ + unprocessed_requests: Annotated[list[RequestDraft], Field(alias='unprocessedRequests')] + """ + Requests that failed to be deleted and can be retried. + """ + + +class BatchDeleteResponse(BaseModel): + """Response containing the result of a batch delete operation.""" + + data: BatchDeleteResult + + +class UnlockRequestsResult(BaseModel): + """Result of unlocking requests in the request queue.""" + + unlocked_count: Annotated[int, Field(alias='unlockedCount', examples=[10])] + """ + Number of requests that were successfully unlocked. + """ + + +class UnlockRequestsResponse(BaseModel): + """Response containing the result of unlocking requests.""" + + data: UnlockRequestsResult + + +class RequestUserData(BaseModel): + """Custom user data attached to the request. Can contain arbitrary fields.""" + + model_config = ConfigDict( + extra='allow', + ) + label: Annotated[str | None, Field(examples=['DETAIL'])] = None + """ + Optional label for categorizing the request. + """ + image: Annotated[AnyUrl | None, Field(examples=['https://picserver1.eu'])] = None + """ + Optional image URL associated with the request. + """ + + +class Request(BaseModel): + """A request stored in the request queue, including its metadata and processing state.""" + + id: Annotated[str, Field(examples=['dnjkDMKLmdlkmlkmld'])] + """ + A unique identifier assigned to the request. + """ + unique_key: Annotated[str, Field(alias='uniqueKey', examples=['GET|60d83e70|e3b0c442|https://apify.com/career'])] + """ + A unique key used for request de-duplication. Requests with the same unique key are considered identical. + """ + url: Annotated[AnyUrl, Field(examples=['https://apify.com/career'])] + """ + The URL of the request. + """ + method: Annotated[str | None, Field(examples=['GET'])] = None + """ + The HTTP method of the request. + """ + retry_count: Annotated[int | None, Field(alias='retryCount', examples=[0])] = None + """ + The number of times this request has been retried. + """ + loaded_url: Annotated[AnyUrl | None, Field(alias='loadedUrl', examples=['https://apify.com/jobs'])] = None + """ + The final URL that was loaded, after redirects (if any). + """ + payload: dict[str, Any] | None = None + """ + The request payload, typically used with POST or PUT requests. + """ + headers: dict[str, Any] | None = None + """ + HTTP headers sent with the request. + """ + user_data: Annotated[RequestUserData | None, Field(alias='userData')] = None + no_retry: Annotated[bool | None, Field(alias='noRetry', examples=[False])] = None + """ + Indicates whether the request should not be retried if processing fails. + """ + error_messages: Annotated[list[str] | None, Field(alias='errorMessages')] = None + """ + Error messages recorded from failed processing attempts. + """ + handled_at: Annotated[AwareDatetime | None, Field(alias='handledAt', examples=['2019-06-16T10:23:31.607Z'])] = None + """ + The timestamp when the request was marked as handled, if applicable. + """ + + +class ListOfRequests(BaseModel): + """A paginated list of requests from the request queue.""" + + items: list[Request] + """ + The array of requests. + """ + count: Annotated[int | None, Field(examples=[2])] = None + """ + The total number of requests matching the query. + """ + limit: Annotated[int, Field(examples=[2])] + """ + The maximum number of requests returned in this response. + """ + exclusive_start_id: Annotated[str | None, Field(alias='exclusiveStartId', examples=['Ihnsp8YrvJ8102Kj'])] = None + """ + The ID of the last request from the previous page, used for pagination. + """ + + +class GetListOfRequestsResponse(BaseModel): + """Response containing a list of requests from the request queue.""" + + data: ListOfRequests + + +class RequestRegistration(BaseModel): + """Result of registering a request in the request queue, either by adding a new request or updating an existing one.""" + + request_id: Annotated[str, Field(alias='requestId', examples=['YiKoxjkaS9gjGTqhF'])] + """ + A unique identifier assigned to the request. + """ + was_already_present: Annotated[bool, Field(alias='wasAlreadyPresent', examples=[False])] + """ + Indicates whether a request with the same unique key already existed in the request queue. If true, no new request was created. + """ + was_already_handled: Annotated[bool, Field(alias='wasAlreadyHandled', examples=[False])] + """ + Indicates whether a request with the same unique key has already been processed by the request queue. + """ + + +class AddRequestResponse(BaseModel): + """Response containing the result of adding a request to the request queue.""" + + data: RequestRegistration + + +class GetRequestResponse(BaseModel): + """Response containing a single request from the request queue.""" + + data: Request + + +class UpdateRequestResponse(BaseModel): + """Response containing the result of updating a request in the request queue.""" + + data: RequestRegistration + + +class HeadRequest(BaseModel): + """A request from the request queue head without lock information.""" + + id: Annotated[str, Field(examples=['8OamqXBCpPHxyH9'])] + """ + A unique identifier assigned to the request. + """ + unique_key: Annotated[str, Field(alias='uniqueKey', examples=['GET|60d83e70|e3b0c442|https://apify.com'])] + """ + A unique key used for request de-duplication. Requests with the same unique key are considered identical. + """ + url: Annotated[AnyUrl, Field(examples=['https://apify.com'])] + """ + The URL of the request. + """ + method: Annotated[str | None, Field(examples=['GET'])] = None + """ + The HTTP method of the request. + """ + retry_count: Annotated[int | None, Field(alias='retryCount', examples=[0])] = None + """ + The number of times this request has been retried. + """ + + +class RequestQueueHead(BaseModel): + """A batch of requests from the request queue head without locking.""" + + limit: Annotated[int, Field(examples=[1000])] + """ + The maximum number of requests returned. + """ + queue_modified_at: Annotated[AwareDatetime, Field(alias='queueModifiedAt', examples=['2018-03-14T23:00:00.000Z'])] + """ + The timestamp when the request queue was last modified. + """ + had_multiple_clients: Annotated[bool, Field(alias='hadMultipleClients', examples=[False])] + """ + Whether the request queue has been accessed by multiple different clients. + """ + items: list[HeadRequest] + """ + The array of requests from the request queue head. + """ + + +class GetHeadResponse(BaseModel): + """Response containing requests from the request queue head without locking.""" + + data: RequestQueueHead + + +class LockedHeadRequest(BaseModel): + """A request from the request queue head that has been locked for processing.""" + + id: Annotated[str, Field(examples=['8OamqXBCpPHxyH9'])] + """ + A unique identifier assigned to the request. + """ + unique_key: Annotated[str, Field(alias='uniqueKey', examples=['GET|60d83e70|e3b0c442|https://apify.com'])] + """ + A unique key used for request de-duplication. Requests with the same unique key are considered identical. + """ + url: Annotated[AnyUrl, Field(examples=['https://apify.com'])] + """ + The URL of the request. + """ + method: Annotated[str | None, Field(examples=['GET'])] = None + """ + The HTTP method of the request. + """ + retry_count: Annotated[int | None, Field(alias='retryCount', examples=[0])] = None + """ + The number of times this request has been retried. + """ + lock_expires_at: Annotated[AwareDatetime, Field(alias='lockExpiresAt', examples=['2022-06-14T23:00:00.000Z'])] + """ + The timestamp when the lock on this request expires. + """ + + +class LockedRequestQueueHead(BaseModel): + """A batch of locked requests from the request queue head.""" + + limit: Annotated[int, Field(examples=[1000])] + """ + The maximum number of requests returned. + """ + queue_modified_at: Annotated[AwareDatetime, Field(alias='queueModifiedAt', examples=['2018-03-14T23:00:00.000Z'])] + """ + The timestamp when the request queue was last modified. Modifications include adding, updating, or removing requests, as well as locking or unlocking requests. + """ + queue_has_locked_requests: Annotated[bool | None, Field(alias='queueHasLockedRequests', examples=[True])] = None + """ + Whether the request queue contains requests locked by any client (either the one calling the endpoint or a different one). + """ + client_key: Annotated[str | None, Field(alias='clientKey', examples=['client-one'])] = None + """ + The client key used for locking the requests. + """ + had_multiple_clients: Annotated[bool, Field(alias='hadMultipleClients', examples=[True])] + """ + Whether the request queue has been accessed by multiple different clients. + """ + lock_secs: Annotated[int, Field(alias='lockSecs', examples=[60])] + """ + The number of seconds the locks will be held. + """ + items: list[LockedHeadRequest] + """ + The array of locked requests from the request queue head. + """ + + +class GetHeadAndLockResponse(BaseModel): + """Response containing locked requests from the request queue head.""" + + data: LockedRequestQueueHead + + +class RequestLockInfo(BaseModel): + """Information about a request lock.""" + + lock_expires_at: Annotated[AwareDatetime, Field(alias='lockExpiresAt', examples=['2022-01-01T00:00:00.000Z'])] + """ + The timestamp when the lock expires. + """ + + +class ProlongRequestLockResponse(BaseModel): + """Response containing updated lock information after prolonging a request lock.""" + + data: RequestLockInfo + + +class WebhookCreate(BaseModel): + is_ad_hoc: Annotated[bool | None, Field(alias='isAdHoc', examples=[False])] = None + event_types: Annotated[list[WebhookEventType], Field(alias='eventTypes', examples=[['ACTOR.RUN.SUCCEEDED']])] + condition: WebhookCondition + idempotency_key: Annotated[str | None, Field(alias='idempotencyKey', examples=['fdSJmdP3nfs7sfk3y'])] = None + ignore_ssl_errors: Annotated[bool | None, Field(alias='ignoreSslErrors', examples=[False])] = None + do_not_retry: Annotated[bool | None, Field(alias='doNotRetry', examples=[False])] = None + request_url: Annotated[AnyUrl, Field(alias='requestUrl', examples=['http://example.com/'])] + payload_template: Annotated[ + str | None, Field(alias='payloadTemplate', examples=['{\\n "userId": {{userId}}...']) + ] = None + headers_template: Annotated[ + str | None, Field(alias='headersTemplate', examples=['{\\n "Authorization": "Bearer ..."}']) + ] = None + description: Annotated[str | None, Field(examples=['this is webhook description'])] = None + should_interpolate_strings: Annotated[bool | None, Field(alias='shouldInterpolateStrings', examples=[False])] = None + + +class CreateWebhookResponse(BaseModel): + data: Webhook + + +class GetWebhookResponse(BaseModel): + data: Webhook + + +class WebhookUpdate(BaseModel): + is_ad_hoc: Annotated[bool | None, Field(alias='isAdHoc', examples=[False])] = None + event_types: Annotated[ + list[WebhookEventType] | None, Field(alias='eventTypes', examples=[['ACTOR.RUN.SUCCEEDED']]) + ] = None + condition: WebhookCondition | None = None + ignore_ssl_errors: Annotated[bool | None, Field(alias='ignoreSslErrors', examples=[False])] = None + do_not_retry: Annotated[bool | None, Field(alias='doNotRetry', examples=[False])] = None + request_url: Annotated[AnyUrl | None, Field(alias='requestUrl', examples=['http://example.com/'])] = None + payload_template: Annotated[ + str | None, Field(alias='payloadTemplate', examples=['{\\n "userId": {{userId}}...']) + ] = None + headers_template: Annotated[ + str | None, Field(alias='headersTemplate', examples=['{\\n "Authorization": "Bearer ..."}']) + ] = None + description: Annotated[str | None, Field(examples=['this is webhook description'])] = None + should_interpolate_strings: Annotated[bool | None, Field(alias='shouldInterpolateStrings', examples=[False])] = None + + +class UpdateWebhookResponse(BaseModel): + data: Webhook + + +class EventData(BaseModel): + actor_id: Annotated[str, Field(alias='actorId', examples=['vvE7iMKuMc5qTHHsR'])] + actor_run_id: Annotated[str, Field(alias='actorRunId', examples=['JgwXN9BdwxGcu9MMF'])] + + +class Call(BaseModel): + started_at: Annotated[AwareDatetime | None, Field(alias='startedAt', examples=['2019-12-12T07:34:14.202Z'])] = None + finished_at: Annotated[AwareDatetime | None, Field(alias='finishedAt', examples=['2019-12-12T07:34:14.202Z'])] = ( + None + ) + error_message: Annotated[str | None, Field(alias='errorMessage', examples=['Cannot send request'])] = None + response_status: Annotated[int | None, Field(alias='responseStatus', examples=[200])] = None + response_body: Annotated[str | None, Field(alias='responseBody', examples=['{"foo": "bar"}'])] = None + + +class WebhookDispatch(BaseModel): + id: Annotated[str, Field(examples=['asdLZtadYvn4mBZmm'])] + user_id: Annotated[str, Field(alias='userId', examples=['wRsJZtadYvn4mBZmm'])] + webhook_id: Annotated[str, Field(alias='webhookId', examples=['asdLZtadYvn4mBZmm'])] + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2019-12-12T07:34:14.202Z'])] + status: Annotated[str, Field(examples=['SUCCEEDED'])] + event_type: Annotated[str, Field(alias='eventType', examples=['ACTOR.RUN.SUCCEEDED'])] + event_data: Annotated[EventData | None, Field(alias='eventData', title='eventData')] = None + calls: Annotated[list[Call] | None, Field(title='calls')] = None + + +class TestWebhookResponse(BaseModel): + data: WebhookDispatch + + +class ListOfWebhookDispatches(PaginationResponse): + items: list[WebhookDispatch] + + +class WebhookDispatchList(BaseModel): + data: ListOfWebhookDispatches | None = None + + +class GetWebhookDispatchResponse(BaseModel): + data: WebhookDispatch + + +class ScheduleAction(BaseModel): + id: Annotated[str, Field(examples=['ZReCs7hkdieq8ZUki'])] + type: Annotated[str, Field(examples=['RUN_ACTOR'])] + actor_id: Annotated[str, Field(alias='actorId', examples=['HKhKmiCMrDgu9eXeE'])] + + +class ScheduleShort(BaseModel): + id: Annotated[str, Field(examples=['asdLZtadYvn4mBZmm'])] + user_id: Annotated[str, Field(alias='userId', examples=['wRsJZtadYvn4mBZmm'])] + name: Annotated[str, Field(examples=['my-schedule'])] + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2019-12-12T07:34:14.202Z'])] + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2019-12-20T06:33:11.202Z'])] + last_run_at: Annotated[AwareDatetime | None, Field(alias='lastRunAt', examples=['2019-04-12T07:33:10.202Z'])] = None + next_run_at: Annotated[AwareDatetime | None, Field(alias='nextRunAt', examples=['2019-04-12T07:34:10.202Z'])] = None + is_enabled: Annotated[bool, Field(alias='isEnabled', examples=[True])] + is_exclusive: Annotated[bool, Field(alias='isExclusive', examples=[True])] + cron_expression: Annotated[str, Field(alias='cronExpression', examples=['* * * * *'])] + timezone: Annotated[str, Field(examples=['UTC'])] + actions: list[ScheduleAction] + + +class ListOfSchedules(BaseModel): + total: Annotated[int, Field(examples=[2])] + offset: Annotated[int, Field(examples=[0])] + limit: Annotated[int, Field(examples=[1000])] + desc: Annotated[bool, Field(examples=[False])] + count: Annotated[int, Field(examples=[2])] + items: list[ScheduleShort] + + +class GetListOfSchedulesResponse(BaseModel): + data: ListOfSchedules + + +class ScheduleActionsRunInput(BaseModel): + body: Annotated[str | None, Field(examples=['{\\n "foo": "actor"\\n}'])] = None + content_type: Annotated[str | None, Field(alias='contentType', examples=['application/json; charset=utf-8'])] = None + + +class ScheduleActionsRunOptions(BaseModel): + build: Annotated[str | None, Field(examples=['latest'])] = None + timeout_secs: Annotated[int | None, Field(alias='timeoutSecs', examples=[60])] = None + memory_mbytes: Annotated[int | None, Field(alias='memoryMbytes', examples=[1024])] = None + restart_on_error: Annotated[bool | None, Field(alias='restartOnError', examples=[False])] = None + + +class ScheduleCreateActions(BaseModel): + type: Annotated[str, Field(examples=['RUN_ACTOR'])] + actor_id: Annotated[str, Field(alias='actorId', examples=['jF8GGEvbEg4Au3NLA'])] + run_input: Annotated[ScheduleActionsRunInput | None, Field(alias='runInput')] = None + run_options: Annotated[ScheduleActionsRunOptions | None, Field(alias='runOptions')] = None + + +class ScheduleCreate(BaseModel): + name: Annotated[str | None, Field(examples=['my-schedule'])] = None + is_enabled: Annotated[bool | None, Field(alias='isEnabled', examples=[True])] = None + is_exclusive: Annotated[bool | None, Field(alias='isExclusive', examples=[True])] = None + cron_expression: Annotated[str | None, Field(alias='cronExpression', examples=['* * * * *'])] = None + timezone: Annotated[str | None, Field(examples=['UTC'])] = None + description: Annotated[str | None, Field(examples=['Schedule of actor ...'])] = None + actions: list[ScheduleCreateActions] | None = None + + +class ScheduleActions(BaseModel): + id: Annotated[str, Field(examples=['c6KfSgoQzFhMk3etc'])] + type: Annotated[str, Field(examples=['RUN_ACTOR'])] + actor_id: Annotated[str, Field(alias='actorId', examples=['jF8GGEvbEg4Au3NLA'])] + run_input: Annotated[ScheduleActionsRunInput | None, Field(alias='runInput')] = None + run_options: Annotated[ScheduleActionsRunOptions | None, Field(alias='runOptions')] = None + + +class Schedule(BaseModel): + id: Annotated[str, Field(examples=['asdLZtadYvn4mBZmm'])] + user_id: Annotated[str, Field(alias='userId', examples=['wRsJZtadYvn4mBZmm'])] + name: Annotated[str, Field(examples=['my-schedule'])] + cron_expression: Annotated[str, Field(alias='cronExpression', examples=['* * * * *'])] + timezone: Annotated[str, Field(examples=['UTC'])] + is_enabled: Annotated[bool, Field(alias='isEnabled', examples=[True])] + is_exclusive: Annotated[bool, Field(alias='isExclusive', examples=[True])] + description: Annotated[str | None, Field(examples=['Schedule of actor ...'])] = None + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2019-12-12T07:34:14.202Z'])] + modified_at: Annotated[AwareDatetime, Field(alias='modifiedAt', examples=['2019-12-20T06:33:11.202Z'])] + next_run_at: Annotated[AwareDatetime | None, Field(alias='nextRunAt', examples=['2019-04-12T07:34:10.202Z'])] = None + last_run_at: Annotated[AwareDatetime | None, Field(alias='lastRunAt', examples=['2019-04-12T07:33:10.202Z'])] = None + actions: list[ScheduleActions] + + +class GetScheduleResponse(BaseModel): + data: Schedule + + +class ScheduleInvoked(BaseModel): + message: Annotated[str, Field(examples=['Schedule invoked'])] + level: Annotated[str, Field(examples=['INFO'])] + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2019-03-26T12:28:00.370Z'])] + + +class GetScheduleLogResponse(BaseModel): + data: list[ScheduleInvoked] + + +class CurrentPricingInfo(BaseModel): + pricing_model: Annotated[str, Field(alias='pricingModel', examples=['FREE'])] + + +class StoreListActor(BaseModel): + id: Annotated[str, Field(examples=['zdc3Pyhyz3m8vjDeM'])] + title: Annotated[str, Field(examples=['My Public Actor'])] + name: Annotated[str, Field(examples=['my-public-actor'])] + username: Annotated[str, Field(examples=['jane35'])] + user_full_name: Annotated[str, Field(alias='userFullName', examples=['Jane H. Doe'])] + description: Annotated[str, Field(examples=['My public actor!'])] + categories: Annotated[list[str] | None, Field(examples=[['MARKETING', 'LEAD_GENERATION']])] = None + notice: str | None = None + picture_url: Annotated[AnyUrl | None, Field(alias='pictureUrl', examples=['https://...'])] = None + user_picture_url: Annotated[AnyUrl | None, Field(alias='userPictureUrl', examples=['https://...'])] = None + url: Annotated[AnyUrl | None, Field(examples=['https://...'])] = None + stats: ActorStats + current_pricing_info: Annotated[CurrentPricingInfo, Field(alias='currentPricingInfo')] + + +class ListOfStoreActors(BaseModel): + total: Annotated[int, Field(examples=[100])] + offset: Annotated[int, Field(examples=[0])] + limit: Annotated[int, Field(examples=[1000])] + desc: Annotated[bool, Field(examples=[False])] + count: Annotated[int, Field(examples=[1])] + items: list[StoreListActor] + + +class GetListOfActorsInStoreResponse(BaseModel): + data: ListOfStoreActors + + +class Profile(BaseModel): + bio: Annotated[str | None, Field(examples=['I started web scraping in 1985 using Altair BASIC.'])] = None + name: Annotated[str | None, Field(examples=['Jane Doe'])] = None + picture_url: Annotated[ + AnyUrl | None, Field(alias='pictureUrl', examples=['https://apify.com/img/anonymous_user_picture.png']) + ] = None + github_username: Annotated[str | None, Field(alias='githubUsername', examples=['torvalds.'])] = None + website_url: Annotated[AnyUrl | None, Field(alias='websiteUrl', examples=['http://www.example.com'])] = None + twitter_username: Annotated[str | None, Field(alias='twitterUsername', examples=['@BillGates'])] = None + + +class UserPublicInfo(BaseModel): + username: Annotated[str, Field(examples=['d7b9MDYsbtX5L7XAj'])] + profile: Profile + + +class GetPublicUserDataResponse(BaseModel): + data: UserPublicInfo + + +class ProxyGroup(BaseModel): + name: Annotated[str, Field(examples=['Group1'])] + description: Annotated[str, Field(examples=['Group1 description'])] + available_count: Annotated[int, Field(alias='availableCount', examples=[10])] + + +class Proxy(BaseModel): + password: Annotated[str, Field(examples=['ad78knd9Jkjd86'])] + groups: list[ProxyGroup] + + +class AvailableProxyGroups(BaseModel): + somegroup: Annotated[int, Field(alias='SOMEGROUP', examples=[20])] + anothergroup: Annotated[int, Field(alias='ANOTHERGROUP', examples=[200])] + + +class Plan(BaseModel): + id: Annotated[str, Field(examples=['Personal'])] + description: Annotated[str, Field(examples=['Cost-effective plan for freelancers, developers and students.'])] + is_enabled: Annotated[bool, Field(alias='isEnabled', examples=[True])] + monthly_base_price_usd: Annotated[float, Field(alias='monthlyBasePriceUsd', examples=[49])] + monthly_usage_credits_usd: Annotated[float, Field(alias='monthlyUsageCreditsUsd', examples=[49])] + usage_discount_percent: Annotated[float, Field(alias='usageDiscountPercent', examples=[0])] + enabled_platform_features: Annotated[ + list[list[Any]], + Field( + alias='enabledPlatformFeatures', + examples=[[['ACTORS'], ['STORAGE'], ['PROXY_SERPS'], ['SCHEDULER'], ['WEBHOOKS']]], + ), + ] + max_monthly_usage_usd: Annotated[float, Field(alias='maxMonthlyUsageUsd', examples=[9999])] + max_actor_memory_gbytes: Annotated[float, Field(alias='maxActorMemoryGbytes', examples=[32])] + max_monthly_actor_compute_units: Annotated[float, Field(alias='maxMonthlyActorComputeUnits', examples=[1000])] + max_monthly_residential_proxy_gbytes: Annotated[ + float, Field(alias='maxMonthlyResidentialProxyGbytes', examples=[10]) + ] + max_monthly_proxy_serps: Annotated[int, Field(alias='maxMonthlyProxySerps', examples=[30000])] + max_monthly_external_data_transfer_gbytes: Annotated[ + float, Field(alias='maxMonthlyExternalDataTransferGbytes', examples=[1000]) + ] + max_actor_count: Annotated[int, Field(alias='maxActorCount', examples=[100])] + max_actor_task_count: Annotated[int, Field(alias='maxActorTaskCount', examples=[1000])] + data_retention_days: Annotated[int, Field(alias='dataRetentionDays', examples=[14])] + available_proxy_groups: Annotated[AvailableProxyGroups, Field(alias='availableProxyGroups')] + team_account_seat_count: Annotated[int, Field(alias='teamAccountSeatCount', examples=[1])] + support_level: Annotated[str, Field(alias='supportLevel', examples=['COMMUNITY'])] + available_add_ons: Annotated[list[str], Field(alias='availableAddOns', examples=[[]])] + + +class EffectivePlatformFeature(BaseModel): + is_enabled: Annotated[bool, Field(alias='isEnabled', examples=[True])] + disabled_reason: Annotated[ + str | None, + Field( + alias='disabledReason', + examples=[ + 'The "Selected public Actors for developers" feature is not enabled for your account. Please upgrade your plan or contact support@apify.com' + ], + ), + ] = None + disabled_reason_type: Annotated[str | None, Field(alias='disabledReasonType', examples=['DISABLED'])] = None + is_trial: Annotated[bool, Field(alias='isTrial', examples=[False])] + trial_expiration_at: Annotated[ + AwareDatetime | None, Field(alias='trialExpirationAt', examples=['2025-01-01T14:00:00.000Z']) + ] = None + + +class EffectivePlatformFeatures(BaseModel): + actors: Annotated[EffectivePlatformFeature, Field(alias='ACTORS')] + storage: Annotated[EffectivePlatformFeature, Field(alias='STORAGE')] + scheduler: Annotated[EffectivePlatformFeature, Field(alias='SCHEDULER')] + proxy: Annotated[EffectivePlatformFeature, Field(alias='PROXY')] + proxy_external_access: Annotated[EffectivePlatformFeature, Field(alias='PROXY_EXTERNAL_ACCESS')] + proxy_residential: Annotated[EffectivePlatformFeature, Field(alias='PROXY_RESIDENTIAL')] + proxy_serps: Annotated[EffectivePlatformFeature, Field(alias='PROXY_SERPS')] + webhooks: Annotated[EffectivePlatformFeature, Field(alias='WEBHOOKS')] + actors_public_all: Annotated[EffectivePlatformFeature, Field(alias='ACTORS_PUBLIC_ALL')] + actors_public_developer: Annotated[EffectivePlatformFeature, Field(alias='ACTORS_PUBLIC_DEVELOPER')] + + +class UserPrivateInfo(BaseModel): + id: Annotated[str, Field(examples=['YiKoxjkaS9gjGTqhF'])] + username: Annotated[str, Field(examples=['myusername'])] + profile: Profile + email: Annotated[EmailStr, Field(examples=['bob@example.com'])] + proxy: Proxy + plan: Plan + effective_platform_features: Annotated[EffectivePlatformFeatures, Field(alias='effectivePlatformFeatures')] + created_at: Annotated[AwareDatetime, Field(alias='createdAt', examples=['2022-11-29T14:48:29.381Z'])] + is_paying: Annotated[bool, Field(alias='isPaying', examples=[True])] + + +class GetPrivateUserDataResponse(BaseModel): + data: UserPrivateInfo + + +class UsageCycle(BaseModel): + start_at: Annotated[AwareDatetime, Field(alias='startAt', examples=['2022-10-02T00:00:00.000Z'])] + end_at: Annotated[AwareDatetime, Field(alias='endAt', examples=['2022-11-01T23:59:59.999Z'])] + + +class PriceTiers(BaseModel): + quantity_above: Annotated[float, Field(alias='quantityAbove', examples=[0])] + discount_percent: Annotated[float, Field(alias='discountPercent', examples=[100])] + tier_quantity: Annotated[float, Field(alias='tierQuantity', examples=[0.39])] + unit_price_usd: Annotated[float, Field(alias='unitPriceUsd', examples=[0])] + price_usd: Annotated[float, Field(alias='priceUsd', examples=[0])] + + +class UsageItem(BaseModel): + quantity: Annotated[float, Field(examples=[2.784475])] + base_amount_usd: Annotated[float, Field(alias='baseAmountUsd', examples=[0.69611875])] + base_unit_price_usd: Annotated[float | None, Field(alias='baseUnitPriceUsd', examples=[0.25])] = None + amount_after_volume_discount_usd: Annotated[ + float | None, Field(alias='amountAfterVolumeDiscountUsd', examples=[0.69611875]) + ] = None + price_tiers: Annotated[list[PriceTiers] | None, Field(alias='priceTiers')] = None + + +class DailyServiceUsages(BaseModel): + date: Annotated[str, Field(examples=['2022-10-02T00:00:00.000Z'])] + service_usage: Annotated[dict[str, UsageItem], Field(alias='serviceUsage')] + total_usage_credits_usd: Annotated[float, Field(alias='totalUsageCreditsUsd', examples=[0.0474385791970591])] + + +class MonthlyUsage(BaseModel): + usage_cycle: Annotated[UsageCycle, Field(alias='usageCycle')] + monthly_service_usage: Annotated[dict[str, UsageItem], Field(alias='monthlyServiceUsage')] + daily_service_usages: Annotated[list[DailyServiceUsages], Field(alias='dailyServiceUsages')] + total_usage_credits_usd_before_volume_discount: Annotated[ + float, Field(alias='totalUsageCreditsUsdBeforeVolumeDiscount', examples=[0.786143673840067]) + ] + total_usage_credits_usd_after_volume_discount: Annotated[ + float, Field(alias='totalUsageCreditsUsdAfterVolumeDiscount', examples=[0.786143673840067]) + ] + + +class GetMonthlyUsageResponse(BaseModel): + data: MonthlyUsage + + +class MonthlyUsageCycle(BaseModel): + start_at: Annotated[AwareDatetime, Field(alias='startAt', examples=['2022-10-02T00:00:00.000Z'])] + end_at: Annotated[AwareDatetime, Field(alias='endAt', examples=['2022-11-01T23:59:59.999Z'])] + + +class Limits(BaseModel): + max_monthly_usage_usd: Annotated[float, Field(alias='maxMonthlyUsageUsd', examples=[300])] + max_monthly_actor_compute_units: Annotated[float, Field(alias='maxMonthlyActorComputeUnits', examples=[1000])] + max_monthly_external_data_transfer_gbytes: Annotated[ + float, Field(alias='maxMonthlyExternalDataTransferGbytes', examples=[7]) + ] + max_monthly_proxy_serps: Annotated[int, Field(alias='maxMonthlyProxySerps', examples=[50])] + max_monthly_residential_proxy_gbytes: Annotated[ + float, Field(alias='maxMonthlyResidentialProxyGbytes', examples=[0.5]) + ] + max_actor_memory_gbytes: Annotated[float, Field(alias='maxActorMemoryGbytes', examples=[16])] + max_actor_count: Annotated[int, Field(alias='maxActorCount', examples=[100])] + max_actor_task_count: Annotated[int, Field(alias='maxActorTaskCount', examples=[1000])] + max_concurrent_actor_jobs: Annotated[int, Field(alias='maxConcurrentActorJobs', examples=[256])] + max_team_account_seat_count: Annotated[int, Field(alias='maxTeamAccountSeatCount', examples=[9])] + data_retention_days: Annotated[int, Field(alias='dataRetentionDays', examples=[90])] + + +class Current(BaseModel): + monthly_usage_usd: Annotated[float, Field(alias='monthlyUsageUsd', examples=[43])] + monthly_actor_compute_units: Annotated[float, Field(alias='monthlyActorComputeUnits', examples=[500.784475])] + monthly_external_data_transfer_gbytes: Annotated[ + float, Field(alias='monthlyExternalDataTransferGbytes', examples=[3.00861903931946]) + ] + monthly_proxy_serps: Annotated[int, Field(alias='monthlyProxySerps', examples=[34])] + monthly_residential_proxy_gbytes: Annotated[float, Field(alias='monthlyResidentialProxyGbytes', examples=[0.4])] + actor_memory_gbytes: Annotated[float, Field(alias='actorMemoryGbytes', examples=[8])] + actor_count: Annotated[int, Field(alias='actorCount', examples=[31])] + actor_task_count: Annotated[int, Field(alias='actorTaskCount', examples=[130])] + active_actor_job_count: Annotated[int, Field(alias='activeActorJobCount', examples=[0])] + team_account_seat_count: Annotated[int, Field(alias='teamAccountSeatCount', examples=[5])] + + +class AccountLimits(BaseModel): + monthly_usage_cycle: Annotated[MonthlyUsageCycle, Field(alias='monthlyUsageCycle')] + limits: Limits + current: Current + + +class GetLimitsResponse(BaseModel): + data: AccountLimits + + +class UpdateLimitsRequest(BaseModel): + max_monthly_usage_usd: Annotated[float | None, Field(alias='maxMonthlyUsageUsd', examples=[300])] = None + """ + If your platform usage in the billing period exceeds the prepaid usage, you will be charged extra. Setting this property you can update your hard limit on monthly platform usage to prevent accidental overage or to limit the extra charges. + + """ + data_retention_days: Annotated[int | None, Field(alias='dataRetentionDays', examples=[90])] = None + """ + Apify securely stores your ten most recent Actor runs indefinitely, ensuring they are always accessible. Unnamed storages and other Actor runs are automatically deleted after the retention period. If you're subscribed, you can change it to keep data for longer or to limit your usage. [Lear more](https://docs.apify.com/platform/storage/usage#data-retention). + + """ diff --git a/src/apify_client/clients/resource_clients/__init__.py b/src/apify_client/_resource_clients/__init__.py similarity index 100% rename from src/apify_client/clients/resource_clients/__init__.py rename to src/apify_client/_resource_clients/__init__.py diff --git a/src/apify_client/_resource_clients/_resource_client.py b/src/apify_client/_resource_clients/_resource_client.py new file mode 100644 index 00000000..5453f233 --- /dev/null +++ b/src/apify_client/_resource_clients/_resource_client.py @@ -0,0 +1,373 @@ +from __future__ import annotations + +import asyncio +import math +import time +from datetime import datetime, timezone +from typing import TYPE_CHECKING, Any +from urllib.parse import urlencode + +from apify_client._consts import DEFAULT_WAIT_FOR_FINISH_SEC, DEFAULT_WAIT_WHEN_JOB_NOT_EXIST_SEC, ActorJobStatus +from apify_client._logging import WithLogDetailsClient +from apify_client._utils import catch_not_found_or_throw, response_to_dict, to_safe_id +from apify_client.errors import ApifyApiError, ApifyClientError + +if TYPE_CHECKING: + from apify_client._client_registry import ClientRegistry, ClientRegistryAsync + from apify_client._http_clients import HttpClient, HttpClientAsync + + +class ResourceClient(metaclass=WithLogDetailsClient): + """Base class for synchronous resource clients. + + Provides URL building, parameter handling, and client creation utilities. + All methods are synchronous and don't perform I/O operations. + """ + + def __init__( + self, + *, + base_url: str, + public_base_url: str, + http_client: HttpClient, + resource_path: str, + client_registry: ClientRegistry, + resource_id: str | None = None, + params: dict | None = None, + ) -> None: + """Initialize the resource client. + + Args: + base_url: API base URL. + public_base_url: Public CDN base URL. + http_client: HTTP client for making requests. + resource_path: Resource endpoint path (e.g., 'actors', 'datasets'). + client_registry: Bundle of client classes for dependency injection. + resource_id: Optional resource ID for single-resource clients. + params: Optional default parameters for all requests. + """ + if resource_path.endswith('/'): + raise ValueError('resource_path must not end with "/"') + + self._base_url = base_url + self._public_base_url = public_base_url + self._http_client = http_client + self._default_params = params or {} + self._resource_path = resource_path + self._resource_id = resource_id + self._client_registry = client_registry + + @property + def _resource_url(self) -> str: + """Build the full resource URL from base URL, path, and optional ID.""" + url = f'{self._base_url}/{self._resource_path}' + if self._resource_id is not None: + url = f'{url}/{to_safe_id(self._resource_id)}' + return url + + @property + def _base_client_kwargs(self) -> dict[str, Any]: + """Base kwargs for creating nested/child clients. + + Returns dict with base_url, public_base_url, http_client, and client_registry. Caller adds + resource_path, resource_id, and params as needed. + """ + return { + 'base_url': self._resource_url, + 'public_base_url': self._public_base_url, + 'http_client': self._http_client, + 'client_registry': self._client_registry, + } + + def _build_url( + self, + path: str | None = None, + *, + public: bool = False, + params: dict | None = None, + ) -> str: + """Build complete URL for API request. + + Args: + path: Optional path segment to append (e.g., 'runs', 'items'). + public: Whether to use public CDN URL instead of API URL. + params: Optional query parameters to append. + + Returns: + Complete URL with optional path and query string. + """ + url = f'{self._resource_url}/{path}' if path else self._resource_url + + if public: + if not url.startswith(self._base_url): + raise ValueError(f'URL {url} does not start with base URL {self._base_url}') + url = url.replace(self._base_url, self._public_base_url, 1) + + if params: + filtered = {k: v for k, v in params.items() if v is not None} + if filtered: + separator = '&' if '?' in url else '?' + url += separator + urlencode(filtered) + + return url + + def _build_params(self, **kwargs: Any) -> dict: + """Merge default params with method params, filtering out None values. + + Args: + **kwargs: Method-specific parameters to merge. + + Returns: + Merged parameters with None values removed. + """ + merged = {**self._default_params, **kwargs} + return {k: v for k, v in merged.items() if v is not None} + + def _wait_for_finish( + self, + url: str, + params: dict, + wait_secs: int | None = None, + ) -> dict | None: + """Wait synchronously for an Actor job (run or build) to finish. + + Polls the job status until it reaches a terminal state or timeout. + Handles 404 errors gracefully (job might not exist yet in replicas). + + Args: + url: Full URL to the job endpoint. + params: Base query parameters to include in each request. + wait_secs: Maximum seconds to wait (None = indefinite). + + Returns: + Job data dict when finished, or None if job doesn't exist after + DEFAULT_WAIT_WHEN_JOB_NOT_EXIST_SEC seconds. + + Raises: + ApifyApiError: If API returns errors other than 404. + """ + started_at = datetime.now(timezone.utc) + should_repeat = True + job: dict | None = None + seconds_elapsed = 0 + + while should_repeat: + wait_for_finish = DEFAULT_WAIT_FOR_FINISH_SEC + if wait_secs is not None: + wait_for_finish = wait_secs - seconds_elapsed + + try: + response = self._http_client.call( + url=url, + method='GET', + params={**params, 'waitForFinish': wait_for_finish}, + ) + job_response = response_to_dict(response) + job = job_response.get('data') if isinstance(job_response, dict) else job_response + seconds_elapsed = math.floor((datetime.now(timezone.utc) - started_at).total_seconds()) + + if not isinstance(job, dict): + raise ApifyClientError( + f'Unexpected response format received from the API. ' + f'Expected dict with "status" field, got: {type(job).__name__}' + ) + + is_terminal = ActorJobStatus(job['status']).is_terminal + is_timed_out = wait_secs is not None and seconds_elapsed >= wait_secs + if is_terminal or is_timed_out: + should_repeat = False + + if not should_repeat: + # Early return here so that we avoid the sleep below if not needed + return job + + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + + # If there are still not found errors after DEFAULT_WAIT_WHEN_JOB_NOT_EXIST_SEC, we give up + # and return None. In such case, the requested record probably really doesn't exist. + if seconds_elapsed > DEFAULT_WAIT_WHEN_JOB_NOT_EXIST_SEC: + return None + + # It might take some time for database replicas to get up-to-date so sleep a bit before retrying + time.sleep(0.25) + + return job + + +class ResourceClientAsync(metaclass=WithLogDetailsClient): + """Base class for asynchronous resource clients. + + Provides URL building, parameter handling, and client creation utilities. + All methods are synchronous and don't perform I/O operations. + """ + + def __init__( + self, + *, + base_url: str, + public_base_url: str, + http_client: HttpClientAsync, + resource_path: str, + client_registry: ClientRegistryAsync, + resource_id: str | None = None, + params: dict | None = None, + ) -> None: + """Initialize the resource client. + + Args: + base_url: API base URL. + public_base_url: Public CDN base URL. + http_client: HTTP client for making requests. + resource_path: Resource endpoint path (e.g., 'actors', 'datasets'). + client_registry: Bundle of client classes for dependency injection. + resource_id: Optional resource ID for single-resource clients. + params: Optional default parameters for all requests. + """ + if resource_path.endswith('/'): + raise ValueError('resource_path must not end with "/"') + + self._base_url = base_url + self._public_base_url = public_base_url + self._http_client = http_client + self._default_params = params or {} + self._resource_path = resource_path + self._resource_id = resource_id + self._client_registry = client_registry + + @property + def _resource_url(self) -> str: + """Build the full resource URL from base URL, path, and optional ID.""" + url = f'{self._base_url}/{self._resource_path}' + if self._resource_id is not None: + url = f'{url}/{to_safe_id(self._resource_id)}' + return url + + @property + def _base_client_kwargs(self) -> dict[str, Any]: + """Base kwargs for creating nested/child clients. + + Returns dict with base_url, public_base_url, http_client, and client_registry. Caller adds + resource_path, resource_id, and params as needed. + """ + return { + 'base_url': self._resource_url, + 'public_base_url': self._public_base_url, + 'http_client': self._http_client, + 'client_registry': self._client_registry, + } + + def _build_url( + self, + path: str | None = None, + *, + public: bool = False, + params: dict | None = None, + ) -> str: + """Build complete URL for API request. + + Args: + path: Optional path segment to append (e.g., 'runs', 'items'). + public: Whether to use public CDN URL instead of API URL. + params: Optional query parameters to append. + + Returns: + Complete URL with optional path and query string. + """ + url = f'{self._resource_url}/{path}' if path else self._resource_url + + if public: + if not url.startswith(self._base_url): + raise ValueError(f'URL {url} does not start with base URL {self._base_url}') + url = url.replace(self._base_url, self._public_base_url, 1) + + if params: + filtered = {k: v for k, v in params.items() if v is not None} + if filtered: + separator = '&' if '?' in url else '?' + url += separator + urlencode(filtered) + + return url + + def _build_params(self, **kwargs: Any) -> dict: + """Merge default params with method params, filtering out None values. + + Args: + **kwargs: Method-specific parameters to merge. + + Returns: + Merged parameters with None values removed. + """ + merged = {**self._default_params, **kwargs} + return {k: v for k, v in merged.items() if v is not None} + + async def _wait_for_finish( + self, + url: str, + params: dict, + wait_secs: int | None = None, + ) -> dict | None: + """Wait synchronously for an Actor job (run or build) to finish. + + Polls the job status until it reaches a terminal state or timeout. + Handles 404 errors gracefully (job might not exist yet in replicas). + + Args: + url: Full URL to the job endpoint. + params: Base query parameters to include in each request. + wait_secs: Maximum seconds to wait (None = indefinite). + + Returns: + Job data dict when finished, or None if job doesn't exist after + DEFAULT_WAIT_WHEN_JOB_NOT_EXIST_SEC seconds. + + Raises: + ApifyApiError: If API returns errors other than 404. + """ + started_at = datetime.now(timezone.utc) + should_repeat = True + job: dict | None = None + seconds_elapsed = 0 + + while should_repeat: + wait_for_finish = DEFAULT_WAIT_FOR_FINISH_SEC + if wait_secs is not None: + wait_for_finish = wait_secs - seconds_elapsed + + try: + response = await self._http_client.call( + url=url, + method='GET', + params={**params, 'waitForFinish': wait_for_finish}, + ) + job_response = response_to_dict(response) + job = job_response.get('data') if isinstance(job_response, dict) else job_response + + if not isinstance(job, dict): + raise ApifyClientError( + f'Unexpected response format received from the API. ' + f'Expected dict with "status" field, got: {type(job).__name__}' + ) + + seconds_elapsed = math.floor((datetime.now(timezone.utc) - started_at).total_seconds()) + is_terminal = ActorJobStatus(job['status']).is_terminal + is_timed_out = wait_secs is not None and seconds_elapsed >= wait_secs + if is_terminal or is_timed_out: + should_repeat = False + + if not should_repeat: + # Early return here so that we avoid the sleep below if not needed + return job + + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + + # If there are still not found errors after DEFAULT_WAIT_WHEN_JOB_NOT_EXIST_SEC, we give up + # and return None. In such case, the requested record probably really doesn't exist. + if seconds_elapsed > DEFAULT_WAIT_WHEN_JOB_NOT_EXIST_SEC: + return None + + # It might take some time for database replicas to get up-to-date so sleep a bit before retrying + await asyncio.sleep(0.25) + + return job diff --git a/src/apify_client/clients/resource_clients/actor.py b/src/apify_client/_resource_clients/actor.py similarity index 80% rename from src/apify_client/clients/resource_clients/actor.py rename to src/apify_client/_resource_clients/actor.py index 5a312a40..f3f15aa8 100644 --- a/src/apify_client/clients/resource_clients/actor.py +++ b/src/apify_client/_resource_clients/actor.py @@ -2,34 +2,49 @@ from typing import TYPE_CHECKING, Any, Literal +from apify_client._models import ( + Actor, + ActorPermissionLevel, + Build, + BuildActorResponse, + GetActorResponse, + GetRunResponse, + Run, + RunOrigin, + UpdateActorResponse, +) +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync from apify_client._utils import ( + catch_not_found_or_throw, encode_key_value_store_record_value, encode_webhook_list_to_base64, - filter_out_none_values_recursively, - maybe_extract_enum_member_value, - parse_date_fields, - pluck_data, -) -from apify_client.clients.base import ResourceClient, ResourceClientAsync -from apify_client.clients.resource_clients.actor_version import ActorVersionClient, ActorVersionClientAsync -from apify_client.clients.resource_clients.actor_version_collection import ( - ActorVersionCollectionClient, - ActorVersionCollectionClientAsync, -) -from apify_client.clients.resource_clients.build import BuildClient, BuildClientAsync -from apify_client.clients.resource_clients.build_collection import BuildCollectionClient, BuildCollectionClientAsync -from apify_client.clients.resource_clients.run import RunClient, RunClientAsync -from apify_client.clients.resource_clients.run_collection import RunCollectionClient, RunCollectionClientAsync -from apify_client.clients.resource_clients.webhook_collection import ( - WebhookCollectionClient, - WebhookCollectionClientAsync, + enum_to_value, + filter_none_values, + response_to_dict, ) +from apify_client.errors import ApifyApiError if TYPE_CHECKING: from decimal import Decimal from logging import Logger - from apify_shared.consts import ActorJobStatus, ActorPermissionLevel, MetaOrigin + from apify_client._consts import ActorJobStatus + from apify_client._resource_clients import ( + ActorVersionClient, + ActorVersionClientAsync, + ActorVersionCollectionClient, + ActorVersionCollectionClientAsync, + BuildClient, + BuildClientAsync, + BuildCollectionClient, + BuildCollectionClientAsync, + RunClient, + RunClientAsync, + RunCollectionClient, + RunCollectionClientAsync, + WebhookCollectionClient, + WebhookCollectionClientAsync, + ) def get_actor_representation( @@ -74,22 +89,29 @@ def get_actor_representation( 'isDeprecated': is_deprecated, 'isAnonymouslyRunnable': is_anonymously_runnable, 'categories': categories, - 'defaultRunOptions': { + 'pricingInfos': pricing_infos, + 'actorPermissionLevel': actor_permission_level, + } + + # Only include defaultRunOptions if at least one field is provided + if any( + [ + default_run_build is not None, + default_run_max_items is not None, + default_run_memory_mbytes is not None, + default_run_timeout_secs is not None, + restart_on_error is not None, + default_run_force_permission_level is not None, + ] + ): + actor_dict['defaultRunOptions'] = { 'build': default_run_build, 'maxItems': default_run_max_items, 'memoryMbytes': default_run_memory_mbytes, 'timeoutSecs': default_run_timeout_secs, 'restartOnError': restart_on_error, 'forcePermissionLevel': default_run_force_permission_level, - }, - 'exampleRunInput': { - 'body': example_run_input_body, - 'contentType': example_run_input_content_type, - }, - 'pricingInfos': pricing_infos, - 'actorPermissionLevel': actor_permission_level, - 'taggedBuilds': tagged_builds, - } + } # Only include actorStandby if at least one field is provided if any( @@ -111,17 +133,36 @@ def get_actor_representation( 'memoryMbytes': actor_standby_memory_mbytes, } + # Only include exampleRunInput if at least one field is provided + if example_run_input_body is not None or example_run_input_content_type is not None: + actor_dict['exampleRunInput'] = { + 'body': example_run_input_body, + 'contentType': example_run_input_content_type, + } + + # Include taggedBuilds if provided + if tagged_builds is not None: + actor_dict['taggedBuilds'] = tagged_builds return actor_dict class ActorClient(ResourceClient): """Sub-client for manipulating a single Actor.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: - resource_path = kwargs.pop('resource_path', 'acts') - super().__init__(*args, resource_path=resource_path, **kwargs) + def __init__( + self, + *, + resource_id: str, + resource_path: str = 'acts', + **kwargs: Any, + ) -> None: + super().__init__( + resource_id=resource_id, + resource_path=resource_path, + **kwargs, + ) - def get(self) -> dict | None: + def get(self) -> Actor | None: """Retrieve the Actor. https://docs.apify.com/api/v2#/reference/actors/actor-object/get-actor @@ -129,7 +170,17 @@ def get(self) -> dict | None: Returns: The retrieved Actor. """ - return self._get() + try: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetActorResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None def update( self, @@ -160,7 +211,7 @@ def update( pricing_infos: list[dict] | None = None, actor_permission_level: ActorPermissionLevel | None = None, tagged_builds: dict[str, None | dict[str, str]] | None = None, - ) -> dict: + ) -> Actor: """Update the Actor with the specified fields. https://docs.apify.com/api/v2#/reference/actors/actor-object/update-actor @@ -231,15 +282,30 @@ def update( actor_permission_level=actor_permission_level, tagged_builds=tagged_builds, ) + cleaned = filter_none_values(actor_representation) - return self._update(filter_out_none_values_recursively(actor_representation)) + response = self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return UpdateActorResponse.model_validate(result).data def delete(self) -> None: """Delete the Actor. https://docs.apify.com/api/v2#/reference/actors/actor-object/delete-actor """ - return self._delete() + try: + self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) def start( self, @@ -255,7 +321,7 @@ def start( force_permission_level: ActorPermissionLevel | None = None, wait_for_finish: int | None = None, webhooks: list[dict] | None = None, - ) -> dict: + ) -> Run: """Start the Actor and immediately return the Run object. https://docs.apify.com/api/v2#/reference/actors/run-collection/run-actor @@ -291,7 +357,7 @@ def start( """ run_input, content_type = encode_key_value_store_record_value(run_input, content_type) - request_params = self._params( + request_params = self._build_params( build=build, maxItems=max_items, maxTotalChargeUsd=max_total_charge_usd, @@ -303,15 +369,16 @@ def start( webhooks=encode_webhook_list_to_base64(webhooks) if webhooks is not None else None, ) - response = self.http_client.call( - url=self._url('runs'), + response = self._http_client.call( + url=self._build_url('runs'), method='POST', headers={'content-type': content_type}, data=run_input, params=request_params, ) - return parse_date_fields(pluck_data(response.json())) + response_as_dict = response_to_dict(response) + return GetRunResponse.model_validate(response_as_dict).data def call( self, @@ -328,7 +395,7 @@ def call( force_permission_level: ActorPermissionLevel | None = None, wait_secs: int | None = None, logger: Logger | None | Literal['default'] = 'default', - ) -> dict | None: + ) -> Run | None: """Start the Actor and wait for it to finish before returning the Run object. It waits indefinitely, unless the wait_secs argument is provided. @@ -376,16 +443,22 @@ def call( webhooks=webhooks, force_permission_level=force_permission_level, ) - if not logger: - return self.root_client.run(started_run['id']).wait_for_finish(wait_secs=wait_secs) + run_client = self._client_registry.run_client( + resource_id=started_run.id, + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + ) - run_client = self.root_client.run(run_id=started_run['id']) + if not logger: + return run_client.wait_for_finish(wait_secs=wait_secs) if logger == 'default': logger = None with run_client.get_status_message_watcher(to_logger=logger), run_client.get_streamed_log(to_logger=logger): - return self.root_client.run(started_run['id']).wait_for_finish(wait_secs=wait_secs) + return run_client.wait_for_finish(wait_secs=wait_secs) def build( self, @@ -395,7 +468,7 @@ def build( tag: str | None = None, use_cache: bool | None = None, wait_for_finish: int | None = None, - ) -> dict: + ) -> Build: """Build the Actor. https://docs.apify.com/api/v2#/reference/actors/build-collection/build-actor @@ -415,7 +488,7 @@ def build( Returns: The build object. """ - request_params = self._params( + request_params = self._build_params( version=version_number, betaPackages=beta_packages, tag=tag, @@ -423,23 +496,29 @@ def build( waitForFinish=wait_for_finish, ) - response = self.http_client.call( - url=self._url('builds'), + response = self._http_client.call( + url=self._build_url('builds'), method='POST', params=request_params, ) - return parse_date_fields(pluck_data(response.json())) + return BuildActorResponse.model_validate(response_to_dict(response)).data def builds(self) -> BuildCollectionClient: """Retrieve a client for the builds of this Actor.""" - return BuildCollectionClient(**self._sub_resource_init_options(resource_path='builds')) + return self._client_registry.build_collection_client( + resource_path='builds', + **self._base_client_kwargs, + ) def runs(self) -> RunCollectionClient: """Retrieve a client for the runs of this Actor.""" - return RunCollectionClient(**self._sub_resource_init_options(resource_path='runs')) + return self._client_registry.run_collection_client( + resource_path='runs', + **self._base_client_kwargs, + ) - async def default_build( + def default_build( self, *, wait_for_finish: int | None = None, @@ -455,25 +534,26 @@ async def default_build( Returns: The resource client for the default build of this Actor. """ - request_params = self._params( + request_params = self._build_params( waitForFinish=wait_for_finish, ) - response = self.http_client.call(url=self._url('builds/default'), method='GET', params=request_params) - data = pluck_data(response.json()) + response = self._http_client.call(url=self._build_url('builds/default'), method='GET', params=request_params) + response_as_dict = response_to_dict(response) - return BuildClient( - base_url=self.base_url, - http_client=self.http_client, - root_client=self.root_client, - resource_id=data['id'], + return self._client_registry.build_client( + resource_id=response_as_dict['data']['id'], + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, ) def last_run( self, *, status: ActorJobStatus | None = None, - origin: MetaOrigin | None = None, + origin: RunOrigin | None = None, ) -> RunClient: """Retrieve the client for the last run of this Actor. @@ -486,20 +566,19 @@ def last_run( Returns: The resource client for the last run of this Actor. """ - return RunClient( - **self._sub_resource_init_options( - resource_id='last', - resource_path='runs', - params=self._params( - status=maybe_extract_enum_member_value(status), - origin=maybe_extract_enum_member_value(origin), - ), - ) + return self._client_registry.run_client( + resource_id='last', + resource_path='runs', + params=self._build_params( + status=enum_to_value(status), + origin=enum_to_value(origin), + ), + **self._base_client_kwargs, ) def versions(self) -> ActorVersionCollectionClient: """Retrieve a client for the versions of this Actor.""" - return ActorVersionCollectionClient(**self._sub_resource_init_options()) + return self._client_registry.actor_version_collection_client(**self._base_client_kwargs) def version(self, version_number: str) -> ActorVersionClient: """Retrieve the client for the specified version of this Actor. @@ -510,11 +589,14 @@ def version(self, version_number: str) -> ActorVersionClient: Returns: The resource client for the specified Actor version. """ - return ActorVersionClient(**self._sub_resource_init_options(resource_id=version_number)) + return self._client_registry.actor_version_client( + resource_id=version_number, + **self._base_client_kwargs, + ) def webhooks(self) -> WebhookCollectionClient: """Retrieve a client for webhooks associated with this Actor.""" - return WebhookCollectionClient(**self._sub_resource_init_options()) + return self._client_registry.webhook_collection_client(**self._base_client_kwargs) def validate_input( self, run_input: Any = None, *, build_tag: str | None = None, content_type: str | None = None @@ -531,12 +613,12 @@ def validate_input( """ run_input, content_type = encode_key_value_store_record_value(run_input, content_type) - self.http_client.call( - url=self._url('validate-input'), + self._http_client.call( + url=self._build_url('validate-input'), method='POST', headers={'content-type': content_type}, data=run_input, - params=self._params(build=build_tag), + params=self._build_params(build=build_tag), ) return True @@ -545,11 +627,20 @@ def validate_input( class ActorClientAsync(ResourceClientAsync): """Async sub-client for manipulating a single Actor.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: - resource_path = kwargs.pop('resource_path', 'acts') - super().__init__(*args, resource_path=resource_path, **kwargs) + def __init__( + self, + *, + resource_id: str, + resource_path: str = 'acts', + **kwargs: Any, + ) -> None: + super().__init__( + resource_id=resource_id, + resource_path=resource_path, + **kwargs, + ) - async def get(self) -> dict | None: + async def get(self) -> Actor | None: """Retrieve the Actor. https://docs.apify.com/api/v2#/reference/actors/actor-object/get-actor @@ -557,7 +648,17 @@ async def get(self) -> dict | None: Returns: The retrieved Actor. """ - return await self._get() + try: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetActorResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None async def update( self, @@ -588,7 +689,7 @@ async def update( pricing_infos: list[dict] | None = None, actor_permission_level: ActorPermissionLevel | None = None, tagged_builds: dict[str, None | dict[str, str]] | None = None, - ) -> dict: + ) -> Actor: """Update the Actor with the specified fields. https://docs.apify.com/api/v2#/reference/actors/actor-object/update-actor @@ -659,15 +760,30 @@ async def update( actor_permission_level=actor_permission_level, tagged_builds=tagged_builds, ) + cleaned = filter_none_values(actor_representation) - return await self._update(filter_out_none_values_recursively(actor_representation)) + response = await self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return UpdateActorResponse.model_validate(result).data async def delete(self) -> None: """Delete the Actor. https://docs.apify.com/api/v2#/reference/actors/actor-object/delete-actor """ - return await self._delete() + try: + await self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) async def start( self, @@ -683,7 +799,7 @@ async def start( force_permission_level: ActorPermissionLevel | None = None, wait_for_finish: int | None = None, webhooks: list[dict] | None = None, - ) -> dict: + ) -> Run: """Start the Actor and immediately return the Run object. https://docs.apify.com/api/v2#/reference/actors/run-collection/run-actor @@ -719,27 +835,28 @@ async def start( """ run_input, content_type = encode_key_value_store_record_value(run_input, content_type) - request_params = self._params( + request_params = self._build_params( build=build, maxItems=max_items, maxTotalChargeUsd=max_total_charge_usd, restartOnError=restart_on_error, memory=memory_mbytes, - timeout=timeout_secs, + timeout_secs=timeout_secs, waitForFinish=wait_for_finish, forcePermissionLevel=force_permission_level.value if force_permission_level is not None else None, webhooks=encode_webhook_list_to_base64(webhooks) if webhooks is not None else None, ) - response = await self.http_client.call( - url=self._url('runs'), + response = await self._http_client.call( + url=self._build_url('runs'), method='POST', headers={'content-type': content_type}, data=run_input, params=request_params, ) - return parse_date_fields(pluck_data(response.json())) + response_as_dict = response_to_dict(response) + return GetRunResponse.model_validate(response_as_dict).data async def call( self, @@ -756,7 +873,7 @@ async def call( force_permission_level: ActorPermissionLevel | None = None, wait_secs: int | None = None, logger: Logger | None | Literal['default'] = 'default', - ) -> dict | None: + ) -> Run | None: """Start the Actor and wait for it to finish before returning the Run object. It waits indefinitely, unless the wait_secs argument is provided. @@ -805,10 +922,16 @@ async def call( force_permission_level=force_permission_level, ) - if not logger: - return await self.root_client.run(started_run['id']).wait_for_finish(wait_secs=wait_secs) + run_client = self._client_registry.run_client( + resource_id=started_run.id, + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + ) - run_client = self.root_client.run(run_id=started_run['id']) + if not logger: + return await run_client.wait_for_finish(wait_secs=wait_secs) if logger == 'default': logger = None @@ -817,7 +940,7 @@ async def call( streamed_log = await run_client.get_streamed_log(to_logger=logger) async with status_redirector, streamed_log: - return await self.root_client.run(started_run['id']).wait_for_finish(wait_secs=wait_secs) + return await run_client.wait_for_finish(wait_secs=wait_secs) async def build( self, @@ -827,7 +950,7 @@ async def build( tag: str | None = None, use_cache: bool | None = None, wait_for_finish: int | None = None, - ) -> dict: + ) -> Build: """Build the Actor. https://docs.apify.com/api/v2#/reference/actors/build-collection/build-actor @@ -847,7 +970,7 @@ async def build( Returns: The build object. """ - request_params = self._params( + request_params = self._build_params( version=version_number, betaPackages=beta_packages, tag=tag, @@ -855,21 +978,28 @@ async def build( waitForFinish=wait_for_finish, ) - response = await self.http_client.call( - url=self._url('builds'), + response = await self._http_client.call( + url=self._build_url('builds'), method='POST', params=request_params, ) - return parse_date_fields(pluck_data(response.json())) + response_as_dict = response_to_dict(response) + return BuildActorResponse.model_validate(response_as_dict).data def builds(self) -> BuildCollectionClientAsync: """Retrieve a client for the builds of this Actor.""" - return BuildCollectionClientAsync(**self._sub_resource_init_options(resource_path='builds')) + return self._client_registry.build_collection_client( + resource_path='builds', + **self._base_client_kwargs, + ) def runs(self) -> RunCollectionClientAsync: """Retrieve a client for the runs of this Actor.""" - return RunCollectionClientAsync(**self._sub_resource_init_options(resource_path='runs')) + return self._client_registry.run_collection_client( + resource_path='runs', + **self._base_client_kwargs, + ) async def default_build( self, @@ -887,29 +1017,30 @@ async def default_build( Returns: The resource client for the default build of this Actor. """ - request_params = self._params( + request_params = self._build_params( waitForFinish=wait_for_finish, ) - response = await self.http_client.call( - url=self._url('builds/default'), + response = await self._http_client.call( + url=self._build_url('builds/default'), method='GET', params=request_params, ) - data = pluck_data(response.json()) - - return BuildClientAsync( - base_url=self.base_url, - http_client=self.http_client, - root_client=self.root_client, - resource_id=data['id'], + response_as_dict = response_to_dict(response) + + return self._client_registry.build_client( + resource_id=response_as_dict['data']['id'], + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, ) def last_run( self, *, status: ActorJobStatus | None = None, - origin: MetaOrigin | None = None, + origin: RunOrigin | None = None, ) -> RunClientAsync: """Retrieve the client for the last run of this Actor. @@ -922,20 +1053,19 @@ def last_run( Returns: The resource client for the last run of this Actor. """ - return RunClientAsync( - **self._sub_resource_init_options( - resource_id='last', - resource_path='runs', - params=self._params( - status=maybe_extract_enum_member_value(status), - origin=maybe_extract_enum_member_value(origin), - ), - ) + return self._client_registry.run_client( + resource_id='last', + resource_path='runs', + params=self._build_params( + status=enum_to_value(status), + origin=enum_to_value(origin), + ), + **self._base_client_kwargs, ) def versions(self) -> ActorVersionCollectionClientAsync: """Retrieve a client for the versions of this Actor.""" - return ActorVersionCollectionClientAsync(**self._sub_resource_init_options()) + return self._client_registry.actor_version_collection_client(**self._base_client_kwargs) def version(self, version_number: str) -> ActorVersionClientAsync: """Retrieve the client for the specified version of this Actor. @@ -946,11 +1076,14 @@ def version(self, version_number: str) -> ActorVersionClientAsync: Returns: The resource client for the specified Actor version. """ - return ActorVersionClientAsync(**self._sub_resource_init_options(resource_id=version_number)) + return self._client_registry.actor_version_client( + resource_id=version_number, + **self._base_client_kwargs, + ) def webhooks(self) -> WebhookCollectionClientAsync: """Retrieve a client for webhooks associated with this Actor.""" - return WebhookCollectionClientAsync(**self._sub_resource_init_options()) + return self._client_registry.webhook_collection_client(**self._base_client_kwargs) async def validate_input( self, run_input: Any = None, *, build_tag: str | None = None, content_type: str | None = None @@ -967,12 +1100,12 @@ async def validate_input( """ run_input, content_type = encode_key_value_store_record_value(run_input, content_type) - await self.http_client.call( - url=self._url('validate-input'), + await self._http_client.call( + url=self._build_url('validate-input'), method='POST', headers={'content-type': content_type}, data=run_input, - params=self._params(build=build_tag), + params=self._build_params(build=build_tag), ) return True diff --git a/src/apify_client/clients/resource_clients/actor_collection.py b/src/apify_client/_resource_clients/actor_collection.py similarity index 86% rename from src/apify_client/clients/resource_clients/actor_collection.py rename to src/apify_client/_resource_clients/actor_collection.py index 0786b650..63ced7b7 100644 --- a/src/apify_client/clients/resource_clients/actor_collection.py +++ b/src/apify_client/_resource_clients/actor_collection.py @@ -1,16 +1,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Literal +from typing import Any, Literal -from apify_client._utils import filter_out_none_values_recursively -from apify_client.clients.base import ResourceCollectionClient, ResourceCollectionClientAsync -from apify_client.clients.resource_clients.actor import get_actor_representation +from apify_client._models import Actor, CreateActorResponse, GetListOfActorsResponse, ListOfActors +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._resource_clients.actor import get_actor_representation +from apify_client._utils import filter_none_values, response_to_dict -if TYPE_CHECKING: - from apify_client.clients.base.resource_collection_client import ListPage - -class ActorCollectionClient(ResourceCollectionClient): +class ActorCollectionClient(ResourceClient): """Sub-client for manipulating Actors.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -25,7 +23,7 @@ def list( offset: int | None = None, desc: bool | None = None, sort_by: Literal['createdAt', 'stats.lastRunStartedAt'] | None = 'createdAt', - ) -> ListPage[dict]: + ) -> ListOfActors: """List the Actors the user has created or used. https://docs.apify.com/api/v2#/reference/actors/actor-collection/get-list-of-actors @@ -40,7 +38,13 @@ def list( Returns: The list of available Actors matching the specified filters. """ - return self._list(my=my, limit=limit, offset=offset, desc=desc, sortBy=sort_by) + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(my=my, limit=limit, offset=offset, desc=desc, sortBy=sort_by), + ) + response_as_dict = response_to_dict(response) + return GetListOfActorsResponse.model_validate(response_as_dict).data def create( self, @@ -68,7 +72,7 @@ def create( actor_standby_idle_timeout_secs: int | None = None, actor_standby_build: str | None = None, actor_standby_memory_mbytes: int | None = None, - ) -> dict: + ) -> Actor: """Create a new Actor. https://docs.apify.com/api/v2#/reference/actors/actor-collection/create-actor @@ -132,10 +136,18 @@ def create( actor_standby_memory_mbytes=actor_standby_memory_mbytes, ) - return self._create(filter_out_none_values_recursively(actor_representation)) + response = self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(), + json=filter_none_values(actor_representation, remove_empty_dicts=True), + ) + + result = response_to_dict(response) + return CreateActorResponse.model_validate(result).data -class ActorCollectionClientAsync(ResourceCollectionClientAsync): +class ActorCollectionClientAsync(ResourceClientAsync): """Async sub-client for manipulating Actors.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -150,7 +162,7 @@ async def list( offset: int | None = None, desc: bool | None = None, sort_by: Literal['createdAt', 'stats.lastRunStartedAt'] | None = 'createdAt', - ) -> ListPage[dict]: + ) -> ListOfActors: """List the Actors the user has created or used. https://docs.apify.com/api/v2#/reference/actors/actor-collection/get-list-of-actors @@ -165,7 +177,13 @@ async def list( Returns: The list of available Actors matching the specified filters. """ - return await self._list(my=my, limit=limit, offset=offset, desc=desc, sortBy=sort_by) + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(my=my, limit=limit, offset=offset, desc=desc, sortBy=sort_by), + ) + response_as_dict = response_to_dict(response) + return GetListOfActorsResponse.model_validate(response_as_dict).data async def create( self, @@ -193,7 +211,7 @@ async def create( actor_standby_idle_timeout_secs: int | None = None, actor_standby_build: str | None = None, actor_standby_memory_mbytes: int | None = None, - ) -> dict: + ) -> Actor: """Create a new Actor. https://docs.apify.com/api/v2#/reference/actors/actor-collection/create-actor @@ -257,4 +275,12 @@ async def create( actor_standby_memory_mbytes=actor_standby_memory_mbytes, ) - return await self._create(filter_out_none_values_recursively(actor_representation)) + response = await self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(), + json=filter_none_values(actor_representation, remove_empty_dicts=True), + ) + + result = response_to_dict(response) + return CreateActorResponse.model_validate(result).data diff --git a/src/apify_client/clients/resource_clients/actor_env_var.py b/src/apify_client/_resource_clients/actor_env_var.py similarity index 58% rename from src/apify_client/clients/resource_clients/actor_env_var.py rename to src/apify_client/_resource_clients/actor_env_var.py index 4fcc3968..b672300b 100644 --- a/src/apify_client/clients/resource_clients/actor_env_var.py +++ b/src/apify_client/_resource_clients/actor_env_var.py @@ -2,8 +2,10 @@ from typing import Any -from apify_client._utils import filter_out_none_values_recursively -from apify_client.clients.base import ResourceClient, ResourceClientAsync +from apify_client._models import EnvVar, GetEnvVarResponse +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils import catch_not_found_or_throw, filter_none_values, response_to_dict +from apify_client.errors import ApifyApiError def get_actor_env_var_representation( @@ -27,7 +29,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'env-vars') super().__init__(*args, resource_path=resource_path, **kwargs) - def get(self) -> dict | None: + def get(self) -> EnvVar | None: """Return information about the Actor environment variable. https://docs.apify.com/api/v2#/reference/actors/environment-variable-object/get-environment-variable @@ -35,7 +37,17 @@ def get(self) -> dict | None: Returns: The retrieved Actor environment variable data. """ - return self._get() + try: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetEnvVarResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None def update( self, @@ -43,7 +55,7 @@ def update( is_secret: bool | None = None, name: str, value: str, - ) -> dict: + ) -> EnvVar: """Update the Actor environment variable with specified fields. https://docs.apify.com/api/v2#/reference/actors/environment-variable-object/update-environment-variable @@ -61,15 +73,30 @@ def update( name=name, value=value, ) + cleaned = filter_none_values(actor_env_var_representation) - return self._update(filter_out_none_values_recursively(actor_env_var_representation)) + response = self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return GetEnvVarResponse.model_validate(result).data def delete(self) -> None: """Delete the Actor environment variable. https://docs.apify.com/api/v2#/reference/actors/environment-variable-object/delete-environment-variable """ - return self._delete() + try: + self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) class ActorEnvVarClientAsync(ResourceClientAsync): @@ -79,7 +106,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'env-vars') super().__init__(*args, resource_path=resource_path, **kwargs) - async def get(self) -> dict | None: + async def get(self) -> EnvVar | None: """Return information about the Actor environment variable. https://docs.apify.com/api/v2#/reference/actors/environment-variable-object/get-environment-variable @@ -87,7 +114,17 @@ async def get(self) -> dict | None: Returns: The retrieved Actor environment variable data. """ - return await self._get() + try: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetEnvVarResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None async def update( self, @@ -95,7 +132,7 @@ async def update( is_secret: bool | None = None, name: str, value: str, - ) -> dict: + ) -> EnvVar: """Update the Actor environment variable with specified fields. https://docs.apify.com/api/v2#/reference/actors/environment-variable-object/update-environment-variable @@ -113,12 +150,27 @@ async def update( name=name, value=value, ) + cleaned = filter_none_values(actor_env_var_representation) - return await self._update(filter_out_none_values_recursively(actor_env_var_representation)) + response = await self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return GetEnvVarResponse.model_validate(result).data async def delete(self) -> None: """Delete the Actor environment variable. https://docs.apify.com/api/v2#/reference/actors/environment-variable-object/delete-environment-variable """ - return await self._delete() + try: + await self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) diff --git a/src/apify_client/clients/resource_clients/actor_env_var_collection.py b/src/apify_client/_resource_clients/actor_env_var_collection.py similarity index 58% rename from src/apify_client/clients/resource_clients/actor_env_var_collection.py rename to src/apify_client/_resource_clients/actor_env_var_collection.py index 217bdd22..1e0a9311 100644 --- a/src/apify_client/clients/resource_clients/actor_env_var_collection.py +++ b/src/apify_client/_resource_clients/actor_env_var_collection.py @@ -1,23 +1,21 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any -from apify_client._utils import filter_out_none_values_recursively -from apify_client.clients.base import ResourceCollectionClient, ResourceCollectionClientAsync -from apify_client.clients.resource_clients.actor_env_var import get_actor_env_var_representation +from apify_client._models import EnvVar, GetEnvVarResponse, GetListOfEnvVarsResponse, ListOfEnvVars +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._resource_clients.actor_env_var import get_actor_env_var_representation +from apify_client._utils import filter_none_values, response_to_dict -if TYPE_CHECKING: - from apify_client.clients.base.resource_collection_client import ListPage - -class ActorEnvVarCollectionClient(ResourceCollectionClient): +class ActorEnvVarCollectionClient(ResourceClient): """Sub-client for manipulating actor env vars.""" def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'env-vars') super().__init__(*args, resource_path=resource_path, **kwargs) - def list(self) -> ListPage[dict]: + def list(self) -> ListOfEnvVars: """List the available actor environment variables. https://docs.apify.com/api/v2#/reference/actors/environment-variable-collection/get-list-of-environment-variables @@ -25,7 +23,13 @@ def list(self) -> ListPage[dict]: Returns: The list of available actor environment variables. """ - return self._list() + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + response_as_dict = response_to_dict(response) + return GetListOfEnvVarsResponse.model_validate(response_as_dict).data def create( self, @@ -33,7 +37,7 @@ def create( is_secret: bool | None = None, name: str, value: str, - ) -> dict: + ) -> EnvVar: """Create a new actor environment variable. https://docs.apify.com/api/v2#/reference/actors/environment-variable-collection/create-environment-variable @@ -52,17 +56,25 @@ def create( value=value, ) - return self._create(filter_out_none_values_recursively(actor_env_var_representation)) + response = self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(), + json=filter_none_values(actor_env_var_representation), + ) + + result = response_to_dict(response) + return GetEnvVarResponse.model_validate(result).data -class ActorEnvVarCollectionClientAsync(ResourceCollectionClientAsync): +class ActorEnvVarCollectionClientAsync(ResourceClientAsync): """Async sub-client for manipulating actor env vars.""" def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'env-vars') super().__init__(*args, resource_path=resource_path, **kwargs) - async def list(self) -> ListPage[dict]: + async def list(self) -> ListOfEnvVars: """List the available actor environment variables. https://docs.apify.com/api/v2#/reference/actors/environment-variable-collection/get-list-of-environment-variables @@ -70,7 +82,13 @@ async def list(self) -> ListPage[dict]: Returns: The list of available actor environment variables. """ - return await self._list() + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + response_as_dict = response_to_dict(response) + return GetListOfEnvVarsResponse.model_validate(response_as_dict).data async def create( self, @@ -78,7 +96,7 @@ async def create( is_secret: bool | None = None, name: str, value: str, - ) -> dict: + ) -> EnvVar: """Create a new actor environment variable. https://docs.apify.com/api/v2#/reference/actors/environment-variable-collection/create-environment-variable @@ -97,4 +115,12 @@ async def create( value=value, ) - return await self._create(filter_out_none_values_recursively(actor_env_var_representation)) + response = await self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(), + json=filter_none_values(actor_env_var_representation), + ) + + result = response_to_dict(response) + return GetEnvVarResponse.model_validate(result).data diff --git a/src/apify_client/clients/resource_clients/actor_version.py b/src/apify_client/_resource_clients/actor_version.py similarity index 58% rename from src/apify_client/clients/resource_clients/actor_version.py rename to src/apify_client/_resource_clients/actor_version.py index fe40e772..54555c13 100644 --- a/src/apify_client/clients/resource_clients/actor_version.py +++ b/src/apify_client/_resource_clients/actor_version.py @@ -2,16 +2,17 @@ from typing import TYPE_CHECKING, Any -from apify_client._utils import filter_out_none_values_recursively, maybe_extract_enum_member_value -from apify_client.clients.base import ResourceClient, ResourceClientAsync -from apify_client.clients.resource_clients.actor_env_var import ActorEnvVarClient, ActorEnvVarClientAsync -from apify_client.clients.resource_clients.actor_env_var_collection import ( - ActorEnvVarCollectionClient, - ActorEnvVarCollectionClientAsync, -) +from apify_client._models import GetVersionResponse, Version, VersionSourceType +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils import catch_not_found_or_throw, enum_to_value, filter_none_values, response_to_dict +from apify_client.errors import ApifyApiError if TYPE_CHECKING: - from apify_shared.consts import ActorSourceType + from apify_client._resource_clients.actor_env_var import ActorEnvVarClient, ActorEnvVarClientAsync + from apify_client._resource_clients.actor_env_var_collection import ( + ActorEnvVarCollectionClient, + ActorEnvVarCollectionClientAsync, + ) def _get_actor_version_representation( @@ -20,7 +21,7 @@ def _get_actor_version_representation( build_tag: str | None = None, env_vars: list[dict] | None = None, apply_env_vars_to_build: bool | None = None, - source_type: ActorSourceType | None = None, + source_type: VersionSourceType | None = None, source_files: list[dict] | None = None, git_repo_url: str | None = None, tarball_url: str | None = None, @@ -31,7 +32,7 @@ def _get_actor_version_representation( 'buildTag': build_tag, 'envVars': env_vars, 'applyEnvVarsToBuild': apply_env_vars_to_build, - 'sourceType': maybe_extract_enum_member_value(source_type), + 'sourceType': enum_to_value(source_type), 'sourceFiles': source_files, 'gitRepoUrl': git_repo_url, 'tarballUrl': tarball_url, @@ -42,11 +43,20 @@ def _get_actor_version_representation( class ActorVersionClient(ResourceClient): """Sub-client for manipulating a single Actor version.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: - resource_path = kwargs.pop('resource_path', 'versions') - super().__init__(*args, resource_path=resource_path, **kwargs) + def __init__( + self, + *, + resource_id: str, + resource_path: str = 'versions', + **kwargs: Any, + ) -> None: + super().__init__( + resource_id=resource_id, + resource_path=resource_path, + **kwargs, + ) - def get(self) -> dict | None: + def get(self) -> Version | None: """Return information about the Actor version. https://docs.apify.com/api/v2#/reference/actors/version-object/get-version @@ -54,7 +64,17 @@ def get(self) -> dict | None: Returns: The retrieved Actor version data. """ - return self._get() + try: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetVersionResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None def update( self, @@ -62,12 +82,12 @@ def update( build_tag: str | None = None, env_vars: list[dict] | None = None, apply_env_vars_to_build: bool | None = None, - source_type: ActorSourceType | None = None, + source_type: VersionSourceType | None = None, source_files: list[dict] | None = None, git_repo_url: str | None = None, tarball_url: str | None = None, github_gist_url: str | None = None, - ) -> dict: + ) -> Version: """Update the Actor version with specified fields. https://docs.apify.com/api/v2#/reference/actors/version-object/update-version @@ -80,13 +100,13 @@ def update( be set to the Actor build process. source_type: What source type is the Actor version using. source_files: Source code comprised of multiple files, each an item of the array. Required when - `source_type` is `ActorSourceType.SOURCE_FILES`. See the API docs for the exact structure. + `source_type` is `VersionSourceType.SOURCE_FILES`. See the API docs for the exact structure. git_repo_url: The URL of a Git repository from which the source code will be cloned. - Required when `source_type` is `ActorSourceType.GIT_REPO`. + Required when `source_type` is `VersionSourceType.GIT_REPO`. tarball_url: The URL of a tarball or a zip archive from which the source code will be downloaded. - Required when `source_type` is `ActorSourceType.TARBALL`. + Required when `source_type` is `VersionSourceType.TARBALL`. github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. - Required when `source_type` is `ActorSourceType.GITHUB_GIST`. + Required when `source_type` is `VersionSourceType.GITHUB_GIST`. Returns: The updated Actor version. @@ -101,19 +121,34 @@ def update( tarball_url=tarball_url, github_gist_url=github_gist_url, ) + cleaned = filter_none_values(actor_version_representation) - return self._update(filter_out_none_values_recursively(actor_version_representation)) + response = self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return GetVersionResponse.model_validate(result).data def delete(self) -> None: """Delete the Actor version. https://docs.apify.com/api/v2#/reference/actors/version-object/delete-version """ - return self._delete() + try: + self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) def env_vars(self) -> ActorEnvVarCollectionClient: """Retrieve a client for the environment variables of this Actor version.""" - return ActorEnvVarCollectionClient(**self._sub_resource_init_options()) + return self._client_registry.actor_env_var_collection_client(**self._base_client_kwargs) def env_var(self, env_var_name: str) -> ActorEnvVarClient: """Retrieve the client for the specified environment variable of this Actor version. @@ -124,17 +159,29 @@ def env_var(self, env_var_name: str) -> ActorEnvVarClient: Returns: The resource client for the specified Actor environment variable. """ - return ActorEnvVarClient(**self._sub_resource_init_options(resource_id=env_var_name)) + return self._client_registry.actor_env_var_client( + resource_id=env_var_name, + **self._base_client_kwargs, + ) class ActorVersionClientAsync(ResourceClientAsync): """Async sub-client for manipulating a single Actor version.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: - resource_path = kwargs.pop('resource_path', 'versions') - super().__init__(*args, resource_path=resource_path, **kwargs) + def __init__( + self, + *, + resource_id: str, + resource_path: str = 'versions', + **kwargs: Any, + ) -> None: + super().__init__( + resource_id=resource_id, + resource_path=resource_path, + **kwargs, + ) - async def get(self) -> dict | None: + async def get(self) -> Version | None: """Return information about the Actor version. https://docs.apify.com/api/v2#/reference/actors/version-object/get-version @@ -142,7 +189,17 @@ async def get(self) -> dict | None: Returns: The retrieved Actor version data. """ - return await self._get() + try: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetVersionResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None async def update( self, @@ -150,12 +207,12 @@ async def update( build_tag: str | None = None, env_vars: list[dict] | None = None, apply_env_vars_to_build: bool | None = None, - source_type: ActorSourceType | None = None, + source_type: VersionSourceType | None = None, source_files: list[dict] | None = None, git_repo_url: str | None = None, tarball_url: str | None = None, github_gist_url: str | None = None, - ) -> dict: + ) -> Version: """Update the Actor version with specified fields. https://docs.apify.com/api/v2#/reference/actors/version-object/update-version @@ -168,13 +225,13 @@ async def update( be set to the Actor build process. source_type: What source type is the Actor version using. source_files: Source code comprised of multiple files, each an item of the array. Required when - `source_type` is `ActorSourceType.SOURCE_FILES`. See the API docs for the exact structure. + `source_type` is `VersionSourceType.SOURCE_FILES`. See the API docs for the exact structure. git_repo_url: The URL of a Git repository from which the source code will be cloned. - Required when `source_type` is `ActorSourceType.GIT_REPO`. + Required when `source_type` is `VersionSourceType.GIT_REPO`. tarball_url: The URL of a tarball or a zip archive from which the source code will be downloaded. - Required when `source_type` is `ActorSourceType.TARBALL`. + Required when `source_type` is `VersionSourceType.TARBALL`. github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. - Required when `source_type` is `ActorSourceType.GITHUB_GIST`. + Required when `source_type` is `VersionSourceType.GITHUB_GIST`. Returns: The updated Actor version. @@ -189,19 +246,34 @@ async def update( tarball_url=tarball_url, github_gist_url=github_gist_url, ) + cleaned = filter_none_values(actor_version_representation) - return await self._update(filter_out_none_values_recursively(actor_version_representation)) + response = await self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return GetVersionResponse.model_validate(result).data async def delete(self) -> None: """Delete the Actor version. https://docs.apify.com/api/v2#/reference/actors/version-object/delete-version """ - return await self._delete() + try: + await self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) def env_vars(self) -> ActorEnvVarCollectionClientAsync: """Retrieve a client for the environment variables of this Actor version.""" - return ActorEnvVarCollectionClientAsync(**self._sub_resource_init_options()) + return self._client_registry.actor_env_var_collection_client(**self._base_client_kwargs) def env_var(self, env_var_name: str) -> ActorEnvVarClientAsync: """Retrieve the client for the specified environment variable of this Actor version. @@ -212,4 +284,7 @@ def env_var(self, env_var_name: str) -> ActorEnvVarClientAsync: Returns: The resource client for the specified Actor environment variable. """ - return ActorEnvVarClientAsync(**self._sub_resource_init_options(resource_id=env_var_name)) + return self._client_registry.actor_env_var_client( + resource_id=env_var_name, + **self._base_client_kwargs, + ) diff --git a/src/apify_client/clients/resource_clients/actor_version_collection.py b/src/apify_client/_resource_clients/actor_version_collection.py similarity index 66% rename from src/apify_client/clients/resource_clients/actor_version_collection.py rename to src/apify_client/_resource_clients/actor_version_collection.py index 91e1e333..98dd67b2 100644 --- a/src/apify_client/clients/resource_clients/actor_version_collection.py +++ b/src/apify_client/_resource_clients/actor_version_collection.py @@ -1,25 +1,27 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any -from apify_client._utils import filter_out_none_values_recursively -from apify_client.clients.base import ResourceCollectionClient, ResourceCollectionClientAsync -from apify_client.clients.resource_clients.actor_version import _get_actor_version_representation +from apify_client._models import ( + GetListOfVersionsResponse, + GetVersionResponse, + ListOfVersions, + Version, + VersionSourceType, +) +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._resource_clients.actor_version import _get_actor_version_representation +from apify_client._utils import filter_none_values, response_to_dict -if TYPE_CHECKING: - from apify_shared.consts import ActorSourceType - from apify_client.clients.base.resource_collection_client import ListPage - - -class ActorVersionCollectionClient(ResourceCollectionClient): +class ActorVersionCollectionClient(ResourceClient): """Sub-client for manipulating Actor versions.""" def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'versions') super().__init__(*args, resource_path=resource_path, **kwargs) - def list(self) -> ListPage[dict]: + def list(self) -> ListOfVersions: """List the available Actor versions. https://docs.apify.com/api/v2#/reference/actors/version-collection/get-list-of-versions @@ -27,7 +29,13 @@ def list(self) -> ListPage[dict]: Returns: The list of available Actor versions. """ - return self._list() + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + response_as_dict = response_to_dict(response) + return GetListOfVersionsResponse.model_validate(response_as_dict).data def create( self, @@ -36,12 +44,12 @@ def create( build_tag: str | None = None, env_vars: list[dict] | None = None, # ty: ignore[invalid-type-form] apply_env_vars_to_build: bool | None = None, - source_type: ActorSourceType, + source_type: VersionSourceType, source_files: list[dict] | None = None, # ty: ignore[invalid-type-form] git_repo_url: str | None = None, tarball_url: str | None = None, github_gist_url: str | None = None, - ) -> dict: + ) -> Version: """Create a new Actor version. https://docs.apify.com/api/v2#/reference/actors/version-collection/create-version @@ -55,13 +63,13 @@ def create( be set to the Actor build process. source_type: What source type is the Actor version using. source_files: Source code comprised of multiple files, each an item of the array. Required - when `source_type` is `ActorSourceType.SOURCE_FILES`. See the API docs for the exact structure. + when `source_type` is `VersionSourceType.SOURCE_FILES`. See the API docs for the exact structure. git_repo_url: The URL of a Git repository from which the source code will be cloned. - Required when `source_type` is `ActorSourceType.GIT_REPO`. + Required when `source_type` is `VersionSourceType.GIT_REPO`. tarball_url: The URL of a tarball or a zip archive from which the source code will be downloaded. - Required when `source_type` is `ActorSourceType.TARBALL`. + Required when `source_type` is `VersionSourceType.TARBALL`. github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. - Required when `source_type` is `ActorSourceType.GITHUB_GIST`. + Required when `source_type` is `VersionSourceType.GITHUB_GIST`. Returns: The created Actor version. @@ -78,17 +86,25 @@ def create( github_gist_url=github_gist_url, ) - return self._create(filter_out_none_values_recursively(actor_version_representation)) + response = self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(), + json=filter_none_values(actor_version_representation), + ) + + result = response_to_dict(response) + return GetVersionResponse.model_validate(result).data -class ActorVersionCollectionClientAsync(ResourceCollectionClientAsync): +class ActorVersionCollectionClientAsync(ResourceClientAsync): """Async sub-client for manipulating Actor versions.""" def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'versions') super().__init__(*args, resource_path=resource_path, **kwargs) - async def list(self) -> ListPage[dict]: + async def list(self) -> ListOfVersions: """List the available Actor versions. https://docs.apify.com/api/v2#/reference/actors/version-collection/get-list-of-versions @@ -96,7 +112,13 @@ async def list(self) -> ListPage[dict]: Returns: The list of available Actor versions. """ - return await self._list() + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + response_as_dict = response_to_dict(response) + return GetListOfVersionsResponse.model_validate(response_as_dict).data async def create( self, @@ -105,12 +127,12 @@ async def create( build_tag: str | None = None, env_vars: list[dict] | None = None, # ty: ignore[invalid-type-form] apply_env_vars_to_build: bool | None = None, - source_type: ActorSourceType, + source_type: VersionSourceType, source_files: list[dict] | None = None, # ty: ignore[invalid-type-form] git_repo_url: str | None = None, tarball_url: str | None = None, github_gist_url: str | None = None, - ) -> dict: + ) -> Version: """Create a new Actor version. https://docs.apify.com/api/v2#/reference/actors/version-collection/create-version @@ -124,13 +146,13 @@ async def create( be set to the Actor build process. source_type: What source type is the Actor version using. source_files: Source code comprised of multiple files, each an item of the array. Required - when `source_type` is `ActorSourceType.SOURCE_FILES`. See the API docs for the exact structure. + when `source_type` is `VersionSourceType.SOURCE_FILES`. See the API docs for the exact structure. git_repo_url: The URL of a Git repository from which the source code will be cloned. - Required when `source_type` is `ActorSourceType.GIT_REPO`. + Required when `source_type` is `VersionSourceType.GIT_REPO`. tarball_url: The URL of a tarball or a zip archive from which the source code will be downloaded. - Required when `source_type` is `ActorSourceType.TARBALL`. + Required when `source_type` is `VersionSourceType.TARBALL`. github_gist_url: The URL of a GitHub Gist from which the source will be downloaded. - Required when `source_type` is `ActorSourceType.GITHUB_GIST`. + Required when `source_type` is `VersionSourceType.GITHUB_GIST`. Returns: The created Actor version. @@ -147,4 +169,12 @@ async def create( github_gist_url=github_gist_url, ) - return await self._create(filter_out_none_values_recursively(actor_version_representation)) + response = await self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(), + json=filter_none_values(actor_version_representation), + ) + + result = response_to_dict(response) + return GetVersionResponse.model_validate(result).data diff --git a/src/apify_client/_resource_clients/build.py b/src/apify_client/_resource_clients/build.py new file mode 100644 index 00000000..28a5cb6f --- /dev/null +++ b/src/apify_client/_resource_clients/build.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from apify_client._models import Build, GetBuildResponse, PostAbortBuildResponse +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils import catch_not_found_or_throw, response_to_dict +from apify_client.errors import ApifyApiError + +if TYPE_CHECKING: + from apify_client._resource_clients.log import LogClient, LogClientAsync + + +class BuildClient(ResourceClient): + """Sub-client for manipulating a single Actor build.""" + + def __init__( + self, + *, + resource_id: str, + resource_path: str = 'actor-builds', + **kwargs: Any, + ) -> None: + super().__init__( + resource_id=resource_id, + resource_path=resource_path, + **kwargs, + ) + + def get(self) -> Build | None: + """Return information about the Actor build. + + https://docs.apify.com/api/v2#/reference/actor-builds/build-object/get-build + + Returns: + The retrieved Actor build data. + """ + try: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetBuildResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None + + def delete(self) -> None: + """Delete the build. + + https://docs.apify.com/api/v2#/reference/actor-builds/delete-build/delete-build + """ + try: + self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + + def abort(self) -> Build: + """Abort the Actor build which is starting or currently running and return its details. + + https://docs.apify.com/api/v2#/reference/actor-builds/abort-build/abort-build + + Returns: + The data of the aborted Actor build. + """ + response = self._http_client.call( + url=self._build_url('abort'), + method='POST', + params=self._build_params(), + ) + result = response_to_dict(response) + return PostAbortBuildResponse.model_validate(result).data + + def get_open_api_definition(self) -> dict | None: + """Return OpenAPI definition of the Actor's build. + + https://docs.apify.com/api/v2/actor-build-openapi-json-get + + Returns: + OpenAPI definition of the Actor's build. + """ + response = self._http_client.call( + url=self._build_url('openapi.json'), + method='GET', + ) + + response_as_dict: dict = response.json() + + return response_as_dict + + def wait_for_finish(self, *, wait_secs: int | None = None) -> Build | None: + """Wait synchronously until the build finishes or the server times out. + + Args: + wait_secs: How long does the client wait for build to finish. None for indefinite. + + Returns: + The Actor build data. If the status on the object is not one of the terminal statuses (SUCCEEDED, FAILED, + TIMED_OUT, ABORTED), then the build has not yet finished. + """ + result = self._wait_for_finish( + url=self._build_url(), + params=self._build_params(), + wait_secs=wait_secs, + ) + return Build.model_validate(result) if result is not None else None + + def log(self) -> LogClient: + """Get the client for the log of the Actor build. + + https://docs.apify.com/api/v2/#/reference/actor-builds/build-log/get-log + + Returns: + A client allowing access to the log of this Actor build. + """ + return self._client_registry.log_client( + resource_path='log', + **self._base_client_kwargs, + ) + + +class BuildClientAsync(ResourceClientAsync): + """Async sub-client for manipulating a single Actor build.""" + + def __init__( + self, + *, + resource_id: str, + resource_path: str = 'actor-builds', + **kwargs: Any, + ) -> None: + super().__init__( + resource_id=resource_id, + resource_path=resource_path, + **kwargs, + ) + + async def get(self) -> Build | None: + """Return information about the Actor build. + + https://docs.apify.com/api/v2#/reference/actor-builds/build-object/get-build + + Returns: + The retrieved Actor build data. + """ + try: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetBuildResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None + + async def abort(self) -> Build: + """Abort the Actor build which is starting or currently running and return its details. + + https://docs.apify.com/api/v2#/reference/actor-builds/abort-build/abort-build + + Returns: + The data of the aborted Actor build. + """ + response = await self._http_client.call( + url=self._build_url('abort'), + method='POST', + params=self._build_params(), + ) + result = response_to_dict(response) + return PostAbortBuildResponse.model_validate(result).data + + async def delete(self) -> None: + """Delete the build. + + https://docs.apify.com/api/v2#/reference/actor-builds/delete-build/delete-build + """ + try: + await self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + + async def get_open_api_definition(self) -> dict | None: + """Return OpenAPI definition of the Actor's build. + + https://docs.apify.com/api/v2/actor-build-openapi-json-get + + Returns: + OpenAPI definition of the Actor's build. + """ + response = await self._http_client.call( + url=self._build_url('openapi.json'), + method='GET', + ) + + response_as_dict: dict = response.json() + + return response_as_dict + + async def wait_for_finish(self, *, wait_secs: int | None = None) -> Build | None: + """Wait synchronously until the build finishes or the server times out. + + Args: + wait_secs: How long does the client wait for build to finish. None for indefinite. + + Returns: + The Actor build data. If the status on the object is not one of the terminal statuses (SUCCEEDED, FAILED, + TIMED_OUT, ABORTED), then the build has not yet finished. + """ + result = await self._wait_for_finish( + url=self._build_url(), + params=self._build_params(), + wait_secs=wait_secs, + ) + return Build.model_validate(result) if result is not None else None + + def log(self) -> LogClientAsync: + """Get the client for the log of the Actor build. + + https://docs.apify.com/api/v2/#/reference/actor-builds/build-log/get-log + + Returns: + A client allowing access to the log of this Actor build. + """ + return self._client_registry.log_client( + resource_path='log', + **self._base_client_kwargs, + ) diff --git a/src/apify_client/clients/resource_clients/build_collection.py b/src/apify_client/_resource_clients/build_collection.py similarity index 67% rename from src/apify_client/clients/resource_clients/build_collection.py rename to src/apify_client/_resource_clients/build_collection.py index 4eada958..b8d37651 100644 --- a/src/apify_client/clients/resource_clients/build_collection.py +++ b/src/apify_client/_resource_clients/build_collection.py @@ -1,14 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any -from apify_client.clients.base import ResourceCollectionClient, ResourceCollectionClientAsync +from apify_client._models import GetListOfBuildsResponse, ListOfBuilds +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils import response_to_dict -if TYPE_CHECKING: - from apify_client.clients.base.resource_collection_client import ListPage - -class BuildCollectionClient(ResourceCollectionClient): +class BuildCollectionClient(ResourceClient): """Sub-client for listing Actor builds.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -21,7 +20,7 @@ def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfBuilds: """List all Actor builds. List all Actor builds, either of a single Actor, or all user's Actors, depending on where this client @@ -38,10 +37,16 @@ def list( Returns: The retrieved Actor builds. """ - return self._list(limit=limit, offset=offset, desc=desc) + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfBuildsResponse.model_validate(response_as_dict).data -class BuildCollectionClientAsync(ResourceCollectionClientAsync): +class BuildCollectionClientAsync(ResourceClientAsync): """Async sub-client for listing Actor builds.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -54,7 +59,7 @@ async def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfBuilds: """List all Actor builds. List all Actor builds, either of a single Actor, or all user's Actors, depending on where this client @@ -71,4 +76,10 @@ async def list( Returns: The retrieved Actor builds. """ - return await self._list(limit=limit, offset=offset, desc=desc) + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfBuildsResponse.model_validate(response_as_dict).data diff --git a/src/apify_client/clients/resource_clients/dataset.py b/src/apify_client/_resource_clients/dataset.py similarity index 85% rename from src/apify_client/clients/resource_clients/dataset.py rename to src/apify_client/_resource_clients/dataset.py index 87d6aab5..0a5bd3b0 100644 --- a/src/apify_client/clients/resource_clients/dataset.py +++ b/src/apify_client/_resource_clients/dataset.py @@ -2,30 +2,56 @@ import warnings from contextlib import asynccontextmanager, contextmanager -from typing import TYPE_CHECKING, Any +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, cast from urllib.parse import urlencode, urlparse, urlunparse -from apify_shared.utils import create_storage_content_signature - -from apify_client._types import ListPage +from apify_client._consts import FAST_OPERATION_TIMEOUT_SECS, STANDARD_OPERATION_TIMEOUT_SECS +from apify_client._models import CreateDatasetResponse, Dataset, DatasetStatistics, GetDatasetStatisticsResponse +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync from apify_client._utils import ( catch_not_found_or_throw, - filter_out_none_values_recursively, - pluck_data, + create_storage_content_signature, + filter_none_values, + response_to_dict, + response_to_list, ) -from apify_client.clients.base import ResourceClient, ResourceClientAsync from apify_client.errors import ApifyApiError if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterator import impit - from apify_shared.consts import StorageGeneralAccess - from apify_client._types import JSONSerializable + from apify_client._consts import JsonSerializable, StorageGeneralAccess + + +@dataclass +class DatasetItemsPage: + """A page of dataset items returned by the `list_items` method. + + Dataset items are arbitrary JSON objects stored in the dataset, so they cannot be + represented by a specific Pydantic model. This class provides pagination metadata + along with the raw items. + """ + + items: list[dict[str, Any]] + """List of dataset items. Each item is a JSON object (dictionary).""" + + total: int + """Total number of items in the dataset.""" + + offset: int + """The offset of the first item in this page.""" + + count: int + """Number of items in this page.""" + + limit: int + """The limit that was used for this request.""" -_SMALL_TIMEOUT = 5 # For fast and common actions. Suitable for idempotent actions. -_MEDIUM_TIMEOUT = 30 # For actions that may take longer. + desc: bool + """Whether the items are sorted in descending order.""" class DatasetClient(ResourceClient): @@ -35,7 +61,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'datasets') super().__init__(*args, resource_path=resource_path, **kwargs) - def get(self) -> dict | None: + def get(self) -> Dataset | None: """Retrieve the dataset. https://docs.apify.com/api/v2#/reference/datasets/dataset/get-dataset @@ -43,9 +69,20 @@ def get(self) -> dict | None: Returns: The retrieved dataset, or None, if it does not exist. """ - return self._get(timeout_secs=_SMALL_TIMEOUT) + try: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + result = response_to_dict(response) + return CreateDatasetResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None - def update(self, *, name: str | None = None, general_access: StorageGeneralAccess | None = None) -> dict: + def update(self, *, name: str | None = None, general_access: StorageGeneralAccess | None = None) -> Dataset: """Update the dataset with specified fields. https://docs.apify.com/api/v2#/reference/datasets/dataset/update-dataset @@ -61,15 +98,32 @@ def update(self, *, name: str | None = None, general_access: StorageGeneralAcces 'name': name, 'generalAccess': general_access, } - - return self._update(filter_out_none_values_recursively(updated_fields), timeout_secs=_SMALL_TIMEOUT) + cleaned = filter_none_values(updated_fields) + + response = self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + result = response_to_dict(response) + return CreateDatasetResponse.model_validate(result).data def delete(self) -> None: """Delete the dataset. https://docs.apify.com/api/v2#/reference/datasets/dataset/delete-dataset """ - return self._delete(timeout_secs=_SMALL_TIMEOUT) + try: + self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) def list_items( self, @@ -86,7 +140,7 @@ def list_items( flatten: list[str] | None = None, view: str | None = None, signature: str | None = None, - ) -> ListPage: + ) -> DatasetItemsPage: """List the items of the dataset. https://docs.apify.com/api/v2#/reference/datasets/item-collection/get-items @@ -122,7 +176,7 @@ def list_items( Returns: A page of the list of dataset items according to the specified filters. """ - request_params = self._params( + request_params = self._build_params( offset=offset, limit=limit, desc=desc, @@ -137,27 +191,27 @@ def list_items( signature=signature, ) - response = self.http_client.call( - url=self._url('items'), + response = self._http_client.call( + url=self._build_url('items'), method='GET', params=request_params, ) - data = response.json() - - return ListPage( - { - 'items': data, - 'total': int(response.headers['x-apify-pagination-total']), - 'offset': int(response.headers['x-apify-pagination-offset']), - 'count': len( - data - ), # because x-apify-pagination-count returns invalid values when hidden/empty items are skipped - 'limit': int( - response.headers['x-apify-pagination-limit'] - ), # API returns 999999999999 when no limit is used - 'desc': bool(response.headers['x-apify-pagination-desc']), - } + # When using signature, API returns items as list directly + try: + items = response_to_list(response) + except ValueError: + items = cast('list', response_to_dict(response)) + + return DatasetItemsPage( + items=items, + total=int(response.headers['x-apify-pagination-total']), + offset=int(response.headers['x-apify-pagination-offset']), + # x-apify-pagination-count returns invalid values when hidden/empty items are skipped + count=len(items), + # API returns 999999999999 when no limit is used + limit=int(response.headers['x-apify-pagination-limit']), + desc=bool(response.headers['x-apify-pagination-desc']), ) def iterate_items( @@ -212,7 +266,7 @@ def iterate_items( should_finish = False read_items = 0 - # We can't rely on ListPage.total because that is updated with a delay, + # We can't rely on DatasetItemsPage.total because that is updated with a delay, # so if you try to read the dataset items right after a run finishes, you could miss some. # Instead, we just read and read until we reach the limit, or until there are no more items to read. while not should_finish: @@ -405,7 +459,7 @@ def get_items_as_bytes( Returns: The dataset items as raw bytes. """ - request_params = self._params( + request_params = self._build_params( format=item_format, offset=offset, limit=limit, @@ -425,8 +479,8 @@ def get_items_as_bytes( signature=signature, ) - response = self.http_client.call( - url=self._url('items'), + response = self._http_client.call( + url=self._build_url('items'), method='GET', params=request_params, ) @@ -501,7 +555,7 @@ def stream_items( """ response = None try: - request_params = self._params( + request_params = self._build_params( format=item_format, offset=offset, limit=limit, @@ -520,8 +574,8 @@ def stream_items( signature=signature, ) - response = self.http_client.call( - url=self._url('items'), + response = self._http_client.call( + url=self._build_url('items'), method='GET', params=request_params, stream=True, @@ -531,7 +585,7 @@ def stream_items( if response: response.close() - def push_items(self, items: JSONSerializable) -> None: + def push_items(self, items: JsonSerializable) -> None: """Push items to the dataset. https://docs.apify.com/api/v2#/reference/datasets/item-collection/put-items @@ -548,17 +602,17 @@ def push_items(self, items: JSONSerializable) -> None: else: json = items - self.http_client.call( - url=self._url('items'), + self._http_client.call( + url=self._build_url('items'), method='POST', headers={'content-type': 'application/json; charset=utf-8'}, - params=self._params(), + params=self._build_params(), data=data, json=json, - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - def get_statistics(self) -> dict | None: + def get_statistics(self) -> DatasetStatistics | None: """Get the dataset statistics. https://docs.apify.com/api/v2#tag/DatasetsStatistics/operation/dataset_statistics_get @@ -567,13 +621,14 @@ def get_statistics(self) -> dict | None: The dataset statistics or None if the dataset does not exist. """ try: - response = self.http_client.call( - url=self._url('statistics'), + response = self._http_client.call( + url=self._build_url('statistics'), method='GET', - params=self._params(), - timeout_secs=_SMALL_TIMEOUT, + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) - return pluck_data(response.json()) + result = response.json() + return GetDatasetStatisticsResponse.model_validate(result).data if result is not None else None except ApifyApiError as exc: catch_not_found_or_throw(exc) @@ -611,7 +666,7 @@ def create_items_public_url( """ dataset = self.get() - request_params = self._params( + request_params = self._build_params( offset=offset, limit=limit, desc=desc, @@ -625,15 +680,15 @@ def create_items_public_url( view=view, ) - if dataset and 'urlSigningSecretKey' in dataset: + if dataset and dataset.url_signing_secret_key: signature = create_storage_content_signature( - resource_id=dataset['id'], - url_signing_secret_key=dataset['urlSigningSecretKey'], + resource_id=dataset.id, + url_signing_secret_key=dataset.url_signing_secret_key, expires_in_millis=expires_in_secs * 1000 if expires_in_secs is not None else None, ) request_params['signature'] = signature - items_public_url = urlparse(self._url('items', public=True)) + items_public_url = urlparse(self._build_url('items', public=True)) filtered_params = {k: v for k, v in request_params.items() if v is not None} if filtered_params: items_public_url = items_public_url._replace(query=urlencode(filtered_params)) @@ -648,7 +703,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'datasets') super().__init__(*args, resource_path=resource_path, **kwargs) - async def get(self) -> dict | None: + async def get(self) -> Dataset | None: """Retrieve the dataset. https://docs.apify.com/api/v2#/reference/datasets/dataset/get-dataset @@ -656,9 +711,20 @@ async def get(self) -> dict | None: Returns: The retrieved dataset, or None, if it does not exist. """ - return await self._get(timeout_secs=_SMALL_TIMEOUT) + try: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + result = response_to_dict(response) + return CreateDatasetResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None - async def update(self, *, name: str | None = None, general_access: StorageGeneralAccess | None = None) -> dict: + async def update(self, *, name: str | None = None, general_access: StorageGeneralAccess | None = None) -> Dataset: """Update the dataset with specified fields. https://docs.apify.com/api/v2#/reference/datasets/dataset/update-dataset @@ -674,15 +740,32 @@ async def update(self, *, name: str | None = None, general_access: StorageGenera 'name': name, 'generalAccess': general_access, } - - return await self._update(filter_out_none_values_recursively(updated_fields), timeout_secs=_SMALL_TIMEOUT) + cleaned = filter_none_values(updated_fields) + + response = await self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + result = response_to_dict(response) + return CreateDatasetResponse.model_validate(result).data async def delete(self) -> None: """Delete the dataset. https://docs.apify.com/api/v2#/reference/datasets/dataset/delete-dataset """ - return await self._delete(timeout_secs=_SMALL_TIMEOUT) + try: + await self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) async def list_items( self, @@ -699,7 +782,7 @@ async def list_items( flatten: list[str] | None = None, view: str | None = None, signature: str | None = None, - ) -> ListPage: + ) -> DatasetItemsPage: """List the items of the dataset. https://docs.apify.com/api/v2#/reference/datasets/item-collection/get-items @@ -735,7 +818,7 @@ async def list_items( Returns: A page of the list of dataset items according to the specified filters. """ - request_params = self._params( + request_params = self._build_params( offset=offset, limit=limit, desc=desc, @@ -750,27 +833,27 @@ async def list_items( signature=signature, ) - response = await self.http_client.call( - url=self._url('items'), + response = await self._http_client.call( + url=self._build_url('items'), method='GET', params=request_params, ) - data = response.json() - - return ListPage( - { - 'items': data, - 'total': int(response.headers['x-apify-pagination-total']), - 'offset': int(response.headers['x-apify-pagination-offset']), - 'count': len( - data - ), # because x-apify-pagination-count returns invalid values when hidden/empty items are skipped - 'limit': int( - response.headers['x-apify-pagination-limit'] - ), # API returns 999999999999 when no limit is used - 'desc': bool(response.headers['x-apify-pagination-desc']), - } + # When using signature, API returns items as list directly + try: + items = response_to_list(response) + except ValueError: + items = cast('list', response_to_dict(response)) + + return DatasetItemsPage( + items=items, + total=int(response.headers['x-apify-pagination-total']), + offset=int(response.headers['x-apify-pagination-offset']), + # x-apify-pagination-count returns invalid values when hidden/empty items are skipped + count=len(items), + # API returns 999999999999 when no limit is used + limit=int(response.headers['x-apify-pagination-limit']), + desc=bool(response.headers['x-apify-pagination-desc']), ) async def iterate_items( @@ -825,7 +908,7 @@ async def iterate_items( should_finish = False read_items = 0 - # We can't rely on ListPage.total because that is updated with a delay, + # We can't rely on DatasetItemsPage.total because that is updated with a delay, # so if you try to read the dataset items right after a run finishes, you could miss some. # Instead, we just read and read until we reach the limit, or until there are no more items to read. while not should_finish: @@ -924,7 +1007,7 @@ async def get_items_as_bytes( Returns: The dataset items as raw bytes. """ - request_params = self._params( + request_params = self._build_params( format=item_format, offset=offset, limit=limit, @@ -944,8 +1027,8 @@ async def get_items_as_bytes( signature=signature, ) - response = await self.http_client.call( - url=self._url('items'), + response = await self._http_client.call( + url=self._build_url('items'), method='GET', params=request_params, ) @@ -1020,7 +1103,7 @@ async def stream_items( """ response = None try: - request_params = self._params( + request_params = self._build_params( format=item_format, offset=offset, limit=limit, @@ -1039,8 +1122,8 @@ async def stream_items( signature=signature, ) - response = await self.http_client.call( - url=self._url('items'), + response = await self._http_client.call( + url=self._build_url('items'), method='GET', params=request_params, stream=True, @@ -1050,7 +1133,7 @@ async def stream_items( if response: await response.aclose() - async def push_items(self, items: JSONSerializable) -> None: + async def push_items(self, items: JsonSerializable) -> None: """Push items to the dataset. https://docs.apify.com/api/v2#/reference/datasets/item-collection/put-items @@ -1067,17 +1150,17 @@ async def push_items(self, items: JSONSerializable) -> None: else: json = items - await self.http_client.call( - url=self._url('items'), + await self._http_client.call( + url=self._build_url('items'), method='POST', headers={'content-type': 'application/json; charset=utf-8'}, - params=self._params(), + params=self._build_params(), data=data, json=json, - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - async def get_statistics(self) -> dict | None: + async def get_statistics(self) -> DatasetStatistics | None: """Get the dataset statistics. https://docs.apify.com/api/v2#tag/DatasetsStatistics/operation/dataset_statistics_get @@ -1086,13 +1169,14 @@ async def get_statistics(self) -> dict | None: The dataset statistics or None if the dataset does not exist. """ try: - response = await self.http_client.call( - url=self._url('statistics'), + response = await self._http_client.call( + url=self._build_url('statistics'), method='GET', - params=self._params(), - timeout_secs=_SMALL_TIMEOUT, + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) - return pluck_data(response.json()) + result = response.json() + return GetDatasetStatisticsResponse.model_validate(result).data if result is not None else None except ApifyApiError as exc: catch_not_found_or_throw(exc) @@ -1130,7 +1214,7 @@ async def create_items_public_url( """ dataset = await self.get() - request_params = self._params( + request_params = self._build_params( offset=offset, limit=limit, desc=desc, @@ -1144,15 +1228,15 @@ async def create_items_public_url( view=view, ) - if dataset and 'urlSigningSecretKey' in dataset: + if dataset and dataset.url_signing_secret_key: signature = create_storage_content_signature( - resource_id=dataset['id'], - url_signing_secret_key=dataset['urlSigningSecretKey'], + resource_id=dataset.id, + url_signing_secret_key=dataset.url_signing_secret_key, expires_in_millis=expires_in_secs * 1000 if expires_in_secs is not None else None, ) request_params['signature'] = signature - items_public_url = urlparse(self._url('items', public=True)) + items_public_url = urlparse(self._build_url('items', public=True)) filtered_params = {k: v for k, v in request_params.items() if v is not None} if filtered_params: items_public_url = items_public_url._replace(query=urlencode(filtered_params)) diff --git a/src/apify_client/clients/resource_clients/dataset_collection.py b/src/apify_client/_resource_clients/dataset_collection.py similarity index 61% rename from src/apify_client/clients/resource_clients/dataset_collection.py rename to src/apify_client/_resource_clients/dataset_collection.py index 602497ce..111b83ee 100644 --- a/src/apify_client/clients/resource_clients/dataset_collection.py +++ b/src/apify_client/_resource_clients/dataset_collection.py @@ -1,15 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any -from apify_client._utils import filter_out_none_values_recursively -from apify_client.clients.base import ResourceCollectionClient, ResourceCollectionClientAsync +from apify_client._models import CreateDatasetResponse, Dataset, GetListOfDatasetsResponse, ListOfDatasets +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils import filter_none_values, response_to_dict -if TYPE_CHECKING: - from apify_client.clients.base.resource_collection_client import ListPage - -class DatasetCollectionClient(ResourceCollectionClient): +class DatasetCollectionClient(ResourceClient): """Sub-client for manipulating datasets.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -23,7 +21,7 @@ def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfDatasets: """List the available datasets. https://docs.apify.com/api/v2#/reference/datasets/dataset-collection/get-list-of-datasets @@ -37,9 +35,15 @@ def list( Returns: The list of available datasets matching the specified filters. """ - return self._list(unnamed=unnamed, limit=limit, offset=offset, desc=desc) - - def get_or_create(self, *, name: str | None = None, schema: dict | None = None) -> dict: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(unnamed=unnamed, limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfDatasetsResponse.model_validate(response_as_dict).data + + def get_or_create(self, *, name: str | None = None, schema: dict | None = None) -> Dataset: """Retrieve a named dataset, or create a new one when it doesn't exist. https://docs.apify.com/api/v2#/reference/datasets/dataset-collection/create-dataset @@ -51,10 +55,18 @@ def get_or_create(self, *, name: str | None = None, schema: dict | None = None) Returns: The retrieved or newly-created dataset. """ - return self._get_or_create(name=name, resource=filter_out_none_values_recursively({'schema': schema})) + response = self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(name=name), + json=filter_none_values({'schema': schema}), + ) + + result = response_to_dict(response) + return CreateDatasetResponse.model_validate(result).data -class DatasetCollectionClientAsync(ResourceCollectionClientAsync): +class DatasetCollectionClientAsync(ResourceClientAsync): """Async sub-client for manipulating datasets.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -68,7 +80,7 @@ async def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfDatasets: """List the available datasets. https://docs.apify.com/api/v2#/reference/datasets/dataset-collection/get-list-of-datasets @@ -82,14 +94,20 @@ async def list( Returns: The list of available datasets matching the specified filters. """ - return await self._list(unnamed=unnamed, limit=limit, offset=offset, desc=desc) + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(unnamed=unnamed, limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfDatasetsResponse.model_validate(response_as_dict).data async def get_or_create( self, *, name: str | None = None, schema: dict | None = None, - ) -> dict: + ) -> Dataset: """Retrieve a named dataset, or create a new one when it doesn't exist. https://docs.apify.com/api/v2#/reference/datasets/dataset-collection/create-dataset @@ -101,4 +119,12 @@ async def get_or_create( Returns: The retrieved or newly-created dataset. """ - return await self._get_or_create(name=name, resource=filter_out_none_values_recursively({'schema': schema})) + response = await self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(name=name), + json=filter_none_values({'schema': schema}), + ) + + result = response_to_dict(response) + return CreateDatasetResponse.model_validate(result).data diff --git a/src/apify_client/clients/resource_clients/key_value_store.py b/src/apify_client/_resource_clients/key_value_store.py similarity index 69% rename from src/apify_client/clients/resource_clients/key_value_store.py rename to src/apify_client/_resource_clients/key_value_store.py index 47bc7003..8ac680d8 100644 --- a/src/apify_client/clients/resource_clients/key_value_store.py +++ b/src/apify_client/_resource_clients/key_value_store.py @@ -1,30 +1,64 @@ from __future__ import annotations +import re from contextlib import asynccontextmanager, contextmanager from http import HTTPStatus from typing import TYPE_CHECKING, Any from urllib.parse import urlencode, urlparse, urlunparse -from apify_shared.utils import create_hmac_signature, create_storage_content_signature - +from apify_client._consts import FAST_OPERATION_TIMEOUT_SECS, STANDARD_OPERATION_TIMEOUT_SECS +from apify_client._models import GetKeyValueStoreResponse, GetListOfKeysResponse, KeyValueStore, ListOfKeys +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync from apify_client._utils import ( catch_not_found_or_throw, + create_hmac_signature, + create_storage_content_signature, encode_key_value_store_record_value, - filter_out_none_values_recursively, - maybe_parse_response, - parse_date_fields, - pluck_data, + filter_none_values, + response_to_dict, ) -from apify_client.clients.base import ResourceClient, ResourceClientAsync -from apify_client.errors import ApifyApiError +from apify_client.errors import ApifyApiError, InvalidResponseBodyError if TYPE_CHECKING: from collections.abc import AsyncIterator, Iterator - from apify_shared.consts import StorageGeneralAccess + from impit import Response + + from apify_client._consts import StorageGeneralAccess + + +def _parse_get_record_response(response: Response) -> Any: + """Parse an HTTP response based on its content type. + + Args: + response: The HTTP response to parse. + + Returns: + Parsed response data (JSON dict/list, text string, or raw bytes). + + Raises: + InvalidResponseBodyError: If the response body cannot be parsed. + """ + if response.status_code == HTTPStatus.NO_CONTENT: + return None -_SMALL_TIMEOUT = 5 # For fast and common actions. Suitable for idempotent actions. -_MEDIUM_TIMEOUT = 30 # For actions that may take longer. + content_type = '' + if 'content-type' in response.headers: + content_type = response.headers['content-type'].split(';')[0].strip() + + try: + if re.search(r'^application/json', content_type, flags=re.IGNORECASE): + response_data = response.json() + elif re.search(r'^application/.*xml$', content_type, flags=re.IGNORECASE) or re.search( + r'^text/', content_type, flags=re.IGNORECASE + ): + response_data = response.text + else: + response_data = response.content + except ValueError as err: + raise InvalidResponseBodyError(response) from err + else: + return response_data class KeyValueStoreClient(ResourceClient): @@ -34,7 +68,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'key-value-stores') super().__init__(*args, resource_path=resource_path, **kwargs) - def get(self) -> dict | None: + def get(self) -> KeyValueStore | None: """Retrieve the key-value store. https://docs.apify.com/api/v2#/reference/key-value-stores/store-object/get-store @@ -42,9 +76,20 @@ def get(self) -> dict | None: Returns: The retrieved key-value store, or None if it does not exist. """ - return self._get(timeout_secs=_SMALL_TIMEOUT) + try: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + result = response_to_dict(response) + return GetKeyValueStoreResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None - def update(self, *, name: str | None = None, general_access: StorageGeneralAccess | None = None) -> dict: + def update(self, *, name: str | None = None, general_access: StorageGeneralAccess | None = None) -> KeyValueStore: """Update the key-value store with specified fields. https://docs.apify.com/api/v2#/reference/key-value-stores/store-object/update-store @@ -60,15 +105,31 @@ def update(self, *, name: str | None = None, general_access: StorageGeneralAcces 'name': name, 'generalAccess': general_access, } + cleaned = filter_none_values(updated_fields) - return self._update(filter_out_none_values_recursively(updated_fields)) + response = self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return GetKeyValueStoreResponse.model_validate(result).data def delete(self) -> None: """Delete the key-value store. https://docs.apify.com/api/v2#/reference/key-value-stores/store-object/delete-store """ - return self._delete(timeout_secs=_SMALL_TIMEOUT) + try: + self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) def list_keys( self, @@ -78,7 +139,7 @@ def list_keys( collection: str | None = None, prefix: str | None = None, signature: str | None = None, - ) -> dict: + ) -> ListOfKeys: """List the keys in the key-value store. https://docs.apify.com/api/v2#/reference/key-value-stores/key-collection/get-list-of-keys @@ -93,7 +154,7 @@ def list_keys( Returns: The list of keys in the key-value store matching the given arguments. """ - request_params = self._params( + request_params = self._build_params( limit=limit, exclusiveStartKey=exclusive_start_key, collection=collection, @@ -101,14 +162,15 @@ def list_keys( signature=signature, ) - response = self.http_client.call( - url=self._url('keys'), + response = self._http_client.call( + url=self._build_url('keys'), method='GET', params=request_params, - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return GetListOfKeysResponse.model_validate(result).data def get_record(self, key: str, signature: str | None = None) -> dict | None: """Retrieve the given record from the key-value store. @@ -123,15 +185,15 @@ def get_record(self, key: str, signature: str | None = None) -> dict | None: The requested record, or None, if the record does not exist. """ try: - response = self.http_client.call( - url=self._url(f'records/{key}'), + response = self._http_client.call( + url=self._build_url(f'records/{key}'), method='GET', - params=self._params(signature=signature, attachment=True), + params=self._build_params(signature=signature, attachment=True), ) return { 'key': key, - 'value': maybe_parse_response(response), + 'value': _parse_get_record_response(response), 'content_type': response.headers['content-type'], } @@ -152,10 +214,10 @@ def record_exists(self, key: str) -> bool: True if the record exists, False otherwise. """ try: - response = self.http_client.call( - url=self._url(f'records/{key}'), + response = self._http_client.call( + url=self._build_url(f'records/{key}'), method='HEAD', - params=self._params(), + params=self._build_params(), ) except ApifyApiError as exc: if exc.status_code == HTTPStatus.NOT_FOUND: @@ -178,10 +240,10 @@ def get_record_as_bytes(self, key: str, signature: str | None = None) -> dict | The requested record, or None, if the record does not exist. """ try: - response = self.http_client.call( - url=self._url(f'records/{key}'), + response = self._http_client.call( + url=self._build_url(f'records/{key}'), method='GET', - params=self._params(signature=signature, attachment=True), + params=self._build_params(signature=signature, attachment=True), ) return { @@ -210,10 +272,10 @@ def stream_record(self, key: str, signature: str | None = None) -> Iterator[dict """ response = None try: - response = self.http_client.call( - url=self._url(f'records/{key}'), + response = self._http_client.call( + url=self._build_url(f'records/{key}'), method='GET', - params=self._params(signature=signature, attachment=True), + params=self._build_params(signature=signature, attachment=True), stream=True, ) @@ -249,10 +311,10 @@ def set_record( headers = {'content-type': content_type} - self.http_client.call( - url=self._url(f'records/{key}'), + self._http_client.call( + url=self._build_url(f'records/{key}'), method='PUT', - params=self._params(), + params=self._build_params(), data=value, headers=headers, ) @@ -265,11 +327,11 @@ def delete_record(self, key: str) -> None: Args: key: The key of the record which to delete. """ - self.http_client.call( - url=self._url(f'records/{key}'), + self._http_client.call( + url=self._build_url(f'records/{key}'), method='DELETE', - params=self._params(), - timeout_secs=_SMALL_TIMEOUT, + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) def get_record_public_url(self, key: str) -> str: @@ -284,17 +346,17 @@ def get_record_public_url(self, key: str) -> str: Returns: A public URL that can be used to access the value of the given key in the KVS. """ - if self.resource_id is None: + if self._resource_id is None: raise ValueError('resource_id cannot be None when generating a public URL') metadata = self.get() - request_params = self._params() + request_params = self._build_params() - if metadata and 'urlSigningSecretKey' in metadata: - request_params['signature'] = create_hmac_signature(metadata['urlSigningSecretKey'], key) + if metadata and metadata.url_signing_secret_key: + request_params['signature'] = create_hmac_signature(metadata.url_signing_secret_key, key) - key_public_url = urlparse(self._url(f'records/{key}', public=True)) + key_public_url = urlparse(self._build_url(f'records/{key}', public=True)) filtered_params = {k: v for k, v in request_params.items() if v is not None} if filtered_params: @@ -327,22 +389,22 @@ def create_keys_public_url( """ metadata = self.get() - request_params = self._params( + request_params = self._build_params( limit=limit, exclusiveStartKey=exclusive_start_key, collection=collection, prefix=prefix, ) - if metadata and 'urlSigningSecretKey' in metadata: + if metadata and metadata.url_signing_secret_key: signature = create_storage_content_signature( - resource_id=metadata['id'], - url_signing_secret_key=metadata['urlSigningSecretKey'], + resource_id=metadata.id, + url_signing_secret_key=metadata.url_signing_secret_key, expires_in_millis=expires_in_secs * 1000 if expires_in_secs is not None else None, ) request_params['signature'] = signature - keys_public_url = urlparse(self._url('keys', public=True)) + keys_public_url = urlparse(self._build_url('keys', public=True)) filtered_params = {k: v for k, v in request_params.items() if v is not None} if filtered_params: @@ -358,7 +420,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'key-value-stores') super().__init__(*args, resource_path=resource_path, **kwargs) - async def get(self) -> dict | None: + async def get(self) -> KeyValueStore | None: """Retrieve the key-value store. https://docs.apify.com/api/v2#/reference/key-value-stores/store-object/get-store @@ -366,9 +428,25 @@ async def get(self) -> dict | None: Returns: The retrieved key-value store, or None if it does not exist. """ - return await self._get(timeout_secs=_SMALL_TIMEOUT) + try: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + result = response_to_dict(response) + return GetKeyValueStoreResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None - async def update(self, *, name: str | None = None, general_access: StorageGeneralAccess | None = None) -> dict: + async def update( + self, + *, + name: str | None = None, + general_access: StorageGeneralAccess | None = None, + ) -> KeyValueStore: """Update the key-value store with specified fields. https://docs.apify.com/api/v2#/reference/key-value-stores/store-object/update-store @@ -384,15 +462,31 @@ async def update(self, *, name: str | None = None, general_access: StorageGenera 'name': name, 'generalAccess': general_access, } + cleaned = filter_none_values(updated_fields) - return await self._update(filter_out_none_values_recursively(updated_fields)) + response = await self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return GetKeyValueStoreResponse.model_validate(result).data async def delete(self) -> None: """Delete the key-value store. https://docs.apify.com/api/v2#/reference/key-value-stores/store-object/delete-store """ - return await self._delete(timeout_secs=_SMALL_TIMEOUT) + try: + await self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) async def list_keys( self, @@ -402,7 +496,7 @@ async def list_keys( collection: str | None = None, prefix: str | None = None, signature: str | None = None, - ) -> dict: + ) -> ListOfKeys: """List the keys in the key-value store. https://docs.apify.com/api/v2#/reference/key-value-stores/key-collection/get-list-of-keys @@ -417,7 +511,7 @@ async def list_keys( Returns: The list of keys in the key-value store matching the given arguments. """ - request_params = self._params( + request_params = self._build_params( limit=limit, exclusiveStartKey=exclusive_start_key, collection=collection, @@ -425,14 +519,15 @@ async def list_keys( signature=signature, ) - response = await self.http_client.call( - url=self._url('keys'), + response = await self._http_client.call( + url=self._build_url('keys'), method='GET', params=request_params, - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return GetListOfKeysResponse.model_validate(result).data async def get_record(self, key: str, signature: str | None = None) -> dict | None: """Retrieve the given record from the key-value store. @@ -447,15 +542,15 @@ async def get_record(self, key: str, signature: str | None = None) -> dict | Non The requested record, or None, if the record does not exist. """ try: - response = await self.http_client.call( - url=self._url(f'records/{key}'), + response = await self._http_client.call( + url=self._build_url(f'records/{key}'), method='GET', - params=self._params(signature=signature, attachment=True), + params=self._build_params(signature=signature, attachment=True), ) return { 'key': key, - 'value': maybe_parse_response(response), + 'value': _parse_get_record_response(response), 'content_type': response.headers['content-type'], } @@ -476,10 +571,10 @@ async def record_exists(self, key: str) -> bool: True if the record exists, False otherwise. """ try: - response = await self.http_client.call( - url=self._url(f'records/{key}'), + response = await self._http_client.call( + url=self._build_url(f'records/{key}'), method='HEAD', - params=self._params(), + params=self._build_params(), ) except ApifyApiError as exc: if exc.status_code == HTTPStatus.NOT_FOUND: @@ -502,10 +597,10 @@ async def get_record_as_bytes(self, key: str, signature: str | None = None) -> d The requested record, or None, if the record does not exist. """ try: - response = await self.http_client.call( - url=self._url(f'records/{key}'), + response = await self._http_client.call( + url=self._build_url(f'records/{key}'), method='GET', - params=self._params(signature=signature, attachment=True), + params=self._build_params(signature=signature, attachment=True), ) return { @@ -534,10 +629,10 @@ async def stream_record(self, key: str, signature: str | None = None) -> AsyncIt """ response = None try: - response = await self.http_client.call( - url=self._url(f'records/{key}'), + response = await self._http_client.call( + url=self._build_url(f'records/{key}'), method='GET', - params=self._params(signature=signature, attachment=True), + params=self._build_params(signature=signature, attachment=True), stream=True, ) @@ -573,10 +668,10 @@ async def set_record( headers = {'content-type': content_type} - await self.http_client.call( - url=self._url(f'records/{key}'), + await self._http_client.call( + url=self._build_url(f'records/{key}'), method='PUT', - params=self._params(), + params=self._build_params(), data=value, headers=headers, ) @@ -589,11 +684,11 @@ async def delete_record(self, key: str) -> None: Args: key: The key of the record which to delete. """ - await self.http_client.call( - url=self._url(f'records/{key}'), + await self._http_client.call( + url=self._build_url(f'records/{key}'), method='DELETE', - params=self._params(), - timeout_secs=_SMALL_TIMEOUT, + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) async def get_record_public_url(self, key: str) -> str: @@ -608,17 +703,17 @@ async def get_record_public_url(self, key: str) -> str: Returns: A public URL that can be used to access the value of the given key in the KVS. """ - if self.resource_id is None: + if self._resource_id is None: raise ValueError('resource_id cannot be None when generating a public URL') metadata = await self.get() - request_params = self._params() + request_params = self._build_params() - if metadata and 'urlSigningSecretKey' in metadata: - request_params['signature'] = create_hmac_signature(metadata['urlSigningSecretKey'], key) + if metadata and metadata.url_signing_secret_key: + request_params['signature'] = create_hmac_signature(metadata.url_signing_secret_key, key) - key_public_url = urlparse(self._url(f'records/{key}', public=True)) + key_public_url = urlparse(self._build_url(f'records/{key}', public=True)) filtered_params = {k: v for k, v in request_params.items() if v is not None} if filtered_params: @@ -651,24 +746,24 @@ async def create_keys_public_url( """ metadata = await self.get() - keys_public_url = urlparse(self._url('keys')) + keys_public_url = urlparse(self._build_url('keys')) - request_params = self._params( + request_params = self._build_params( limit=limit, exclusiveStartKey=exclusive_start_key, collection=collection, prefix=prefix, ) - if metadata and 'urlSigningSecretKey' in metadata: + if metadata and metadata.url_signing_secret_key: signature = create_storage_content_signature( - resource_id=metadata['id'], - url_signing_secret_key=metadata['urlSigningSecretKey'], + resource_id=metadata.id, + url_signing_secret_key=metadata.url_signing_secret_key, expires_in_millis=expires_in_secs * 1000 if expires_in_secs is not None else None, ) request_params['signature'] = signature - keys_public_url = urlparse(self._url('keys', public=True)) + keys_public_url = urlparse(self._build_url('keys', public=True)) filtered_params = {k: v for k, v in request_params.items() if v is not None} if filtered_params: keys_public_url = keys_public_url._replace(query=urlencode(filtered_params)) diff --git a/src/apify_client/clients/resource_clients/key_value_store_collection.py b/src/apify_client/_resource_clients/key_value_store_collection.py similarity index 62% rename from src/apify_client/clients/resource_clients/key_value_store_collection.py rename to src/apify_client/_resource_clients/key_value_store_collection.py index 8af38903..466df90f 100644 --- a/src/apify_client/clients/resource_clients/key_value_store_collection.py +++ b/src/apify_client/_resource_clients/key_value_store_collection.py @@ -1,15 +1,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any -from apify_client._utils import filter_out_none_values_recursively -from apify_client.clients.base import ResourceCollectionClient, ResourceCollectionClientAsync +from apify_client._models import ( + CreateKeyValueStoreResponse, + GetListOfKeyValueStoresResponse, + KeyValueStore, + ListOfKeyValueStores, +) +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils import filter_none_values, response_to_dict -if TYPE_CHECKING: - from apify_client.clients.base.resource_collection_client import ListPage - -class KeyValueStoreCollectionClient(ResourceCollectionClient): +class KeyValueStoreCollectionClient(ResourceClient): """Sub-client for manipulating key-value stores.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -23,7 +26,7 @@ def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfKeyValueStores: """List the available key-value stores. https://docs.apify.com/api/v2#/reference/key-value-stores/store-collection/get-list-of-key-value-stores @@ -37,14 +40,20 @@ def list( Returns: The list of available key-value stores matching the specified filters. """ - return self._list(unnamed=unnamed, limit=limit, offset=offset, desc=desc) + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(unnamed=unnamed, limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfKeyValueStoresResponse.model_validate(response_as_dict).data def get_or_create( self, *, name: str | None = None, schema: dict | None = None, - ) -> dict: + ) -> KeyValueStore: """Retrieve a named key-value store, or create a new one when it doesn't exist. https://docs.apify.com/api/v2#/reference/key-value-stores/store-collection/create-key-value-store @@ -56,10 +65,18 @@ def get_or_create( Returns: The retrieved or newly-created key-value store. """ - return self._get_or_create(name=name, resource=filter_out_none_values_recursively({'schema': schema})) + response = self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(name=name), + json=filter_none_values({'schema': schema}), + ) + + result = response_to_dict(response) + return CreateKeyValueStoreResponse.model_validate(result).data -class KeyValueStoreCollectionClientAsync(ResourceCollectionClientAsync): +class KeyValueStoreCollectionClientAsync(ResourceClientAsync): """Async sub-client for manipulating key-value stores.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -73,7 +90,7 @@ async def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfKeyValueStores: """List the available key-value stores. https://docs.apify.com/api/v2#/reference/key-value-stores/store-collection/get-list-of-key-value-stores @@ -87,14 +104,20 @@ async def list( Returns: The list of available key-value stores matching the specified filters. """ - return await self._list(unnamed=unnamed, limit=limit, offset=offset, desc=desc) + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(unnamed=unnamed, limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfKeyValueStoresResponse.model_validate(response_as_dict).data async def get_or_create( self, *, name: str | None = None, schema: dict | None = None, - ) -> dict: + ) -> KeyValueStore: """Retrieve a named key-value store, or create a new one when it doesn't exist. https://docs.apify.com/api/v2#/reference/key-value-stores/store-collection/create-key-value-store @@ -106,4 +129,12 @@ async def get_or_create( Returns: The retrieved or newly-created key-value store. """ - return await self._get_or_create(name=name, resource=filter_out_none_values_recursively({'schema': schema})) + response = await self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(name=name), + json=filter_none_values({'schema': schema}), + ) + + result = response_to_dict(response) + return CreateKeyValueStoreResponse.model_validate(result).data diff --git a/src/apify_client/clients/resource_clients/log.py b/src/apify_client/_resource_clients/log.py similarity index 92% rename from src/apify_client/clients/resource_clients/log.py rename to src/apify_client/_resource_clients/log.py index d333fb6e..6aefa5e9 100644 --- a/src/apify_client/clients/resource_clients/log.py +++ b/src/apify_client/_resource_clients/log.py @@ -11,8 +11,10 @@ from threading import Thread from typing import TYPE_CHECKING, Any, cast +from typing_extensions import Self + +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync from apify_client._utils import catch_not_found_or_throw -from apify_client.clients.base import ResourceClient, ResourceClientAsync from apify_client.errors import ApifyApiError if TYPE_CHECKING: @@ -20,9 +22,9 @@ from types import TracebackType import impit - from typing_extensions import Self - from apify_client.clients import RunClient, RunClientAsync + from apify_client._models import Run + from apify_client._resource_clients.run import RunClient, RunClientAsync class LogClient(ResourceClient): @@ -44,10 +46,10 @@ def get(self, *, raw: bool = False) -> str | None: The retrieved log, or None, if it does not exist. """ try: - response = self.http_client.call( - url=self.url, + response = self._http_client.call( + url=self._build_url(), method='GET', - params=self._params(raw=raw), + params=self._build_params(raw=raw), ) return response.text # noqa: TRY300 @@ -69,10 +71,10 @@ def get_as_bytes(self, *, raw: bool = False) -> bytes | None: The retrieved log as raw bytes, or None, if it does not exist. """ try: - response = self.http_client.call( - url=self.url, + response = self._http_client.call( + url=self._build_url(), method='GET', - params=self._params(raw=raw), + params=self._build_params(raw=raw), ) return response.content # noqa: TRY300 @@ -96,10 +98,10 @@ def stream(self, *, raw: bool = False) -> Iterator[impit.Response | None]: """ response = None try: - response = self.http_client.call( - url=self.url, + response = self._http_client.call( + url=self._build_url(), method='GET', - params=self._params(stream=True, raw=raw), + params=self._build_params(stream=True, raw=raw), stream=True, ) @@ -131,10 +133,10 @@ async def get(self, *, raw: bool = False) -> str | None: The retrieved log, or None, if it does not exist. """ try: - response = await self.http_client.call( - url=self.url, + response = await self._http_client.call( + url=self._build_url(), method='GET', - params=self._params(raw=raw), + params=self._build_params(raw=raw), ) return response.text # noqa: TRY300 @@ -156,10 +158,10 @@ async def get_as_bytes(self, *, raw: bool = False) -> bytes | None: The retrieved log as raw bytes, or None, if it does not exist. """ try: - response = await self.http_client.call( - url=self.url, + response = await self._http_client.call( + url=self._build_url(), method='GET', - params=self._params(raw=raw), + params=self._build_params(raw=raw), ) return response.content # noqa: TRY300 @@ -183,10 +185,10 @@ async def stream(self, *, raw: bool = False) -> AsyncIterator[impit.Response | N """ response = None try: - response = await self.http_client.call( - url=self.url, + response = await self._http_client.call( + url=self._build_url(), method='GET', - params=self._params(stream=True, raw=raw), + params=self._build_params(stream=True, raw=raw), stream=True, ) @@ -404,25 +406,25 @@ def __init__(self, *, to_logger: logging.Logger, check_period: timedelta = timed self._check_period = check_period.total_seconds() self._last_status_message = '' - def _log_run_data(self, run_data: dict[str, Any] | None) -> bool: + def _log_run_data(self, run_data: Run | None) -> bool: """Get relevant run data, log them if changed and return `True` if more data is expected. Args: - run_data: The dictionary that contains the run data. + run_data: The Run model that contains the run data. Returns: `True` if more data is expected, `False` otherwise. """ if run_data is not None: - status = run_data.get('status', 'Unknown status') - status_message = run_data.get('statusMessage', '') + status = run_data.status.value if run_data.status else 'Unknown status' + status_message = run_data.status_message or '' new_status_message = f'Status: {status}, Message: {status_message}' if new_status_message != self._last_status_message: self._last_status_message = new_status_message self._to_logger.info(new_status_message) - return not (run_data.get('isStatusMessageTerminal', False)) + return not (run_data.is_status_message_terminal or False) return True diff --git a/src/apify_client/clients/resource_clients/request_queue.py b/src/apify_client/_resource_clients/request_queue.py similarity index 58% rename from src/apify_client/clients/resource_clients/request_queue.py rename to src/apify_client/_resource_clients/request_queue.py index c3ee1bf6..9cf67bda 100644 --- a/src/apify_client/clients/resource_clients/request_queue.py +++ b/src/apify_client/_resource_clients/request_queue.py @@ -5,23 +5,48 @@ import math from collections.abc import Iterable from queue import Queue -from typing import TYPE_CHECKING, Any, TypedDict +from typing import TYPE_CHECKING, Any from more_itertools import constrained_batches +from apify_client._consts import FAST_OPERATION_TIMEOUT_SECS, STANDARD_OPERATION_TIMEOUT_SECS +from apify_client._models import ( + AddedRequest, + AddRequestResponse, + BatchAddResponse, + BatchAddResult, + BatchDeleteResponse, + BatchDeleteResult, + GetHeadAndLockResponse, + GetHeadResponse, + GetListOfRequestsResponse, + GetRequestQueueResponse, + GetRequestResponse, + ListOfRequests, + LockedRequestQueueHead, + ProlongRequestLockResponse, + Request, + RequestDraft, + RequestLockInfo, + RequestQueue, + RequestQueueHead, + RequestRegistration, + UnlockRequestsResponse, + UnlockRequestsResult, +) +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync from apify_client._utils import ( catch_not_found_or_throw, - filter_out_none_values_recursively, - parse_date_fields, - pluck_data, + filter_none_values, + response_to_dict, ) -from apify_client.clients.base import ResourceClient, ResourceClientAsync from apify_client.errors import ApifyApiError if TYPE_CHECKING: from datetime import timedelta - from apify_shared.consts import StorageGeneralAccess + from apify_client._consts import StorageGeneralAccess + logger = logging.getLogger(__name__) @@ -29,21 +54,6 @@ _MAX_PAYLOAD_SIZE_BYTES = 9 * 1024 * 1024 # 9 MB _SAFETY_BUFFER_PERCENT = 0.01 / 100 # 0.01% -_SMALL_TIMEOUT = 5 # For fast and common actions. Suitable for idempotent actions. -_MEDIUM_TIMEOUT = 30 # For actions that may take longer. - - -class BatchAddRequestsResult(TypedDict): - """Result of the batch add requests operation. - - Args: - processedRequests: List of successfully added requests. - unprocessedRequests: List of requests that failed to be added. - """ - - processedRequests: list[dict] - unprocessedRequests: list[dict] - class RequestQueueClient(ResourceClient): """Sub-client for manipulating a single request queue.""" @@ -63,7 +73,7 @@ def __init__( # noqa: D417 super().__init__(*args, resource_path=resource_path, **kwargs) self.client_key = client_key - def get(self) -> dict | None: + def get(self) -> RequestQueue | None: """Retrieve the request queue. https://docs.apify.com/api/v2#/reference/request-queues/queue/get-request-queue @@ -71,9 +81,20 @@ def get(self) -> dict | None: Returns: The retrieved request queue, or None, if it does not exist. """ - return self._get(timeout_secs=_SMALL_TIMEOUT) + try: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + result = response_to_dict(response) + return GetRequestQueueResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None - def update(self, *, name: str | None = None, general_access: StorageGeneralAccess | None = None) -> dict: + def update(self, *, name: str | None = None, general_access: StorageGeneralAccess | None = None) -> RequestQueue: """Update the request queue with specified fields. https://docs.apify.com/api/v2#/reference/request-queues/queue/update-request-queue @@ -89,17 +110,34 @@ def update(self, *, name: str | None = None, general_access: StorageGeneralAcces 'name': name, 'generalAccess': general_access, } + cleaned = filter_none_values(updated_fields) - return self._update(filter_out_none_values_recursively(updated_fields), timeout_secs=_SMALL_TIMEOUT) + response = self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + result = response_to_dict(response) + return GetRequestQueueResponse.model_validate(result).data def delete(self) -> None: """Delete the request queue. https://docs.apify.com/api/v2#/reference/request-queues/queue/delete-request-queue """ - return self._delete(timeout_secs=_SMALL_TIMEOUT) + try: + self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) - def list_head(self, *, limit: int | None = None) -> dict: + def list_head(self, *, limit: int | None = None) -> RequestQueueHead: """Retrieve a given number of requests from the beginning of the queue. https://docs.apify.com/api/v2#/reference/request-queues/queue-head/get-head @@ -110,18 +148,19 @@ def list_head(self, *, limit: int | None = None) -> dict: Returns: The desired number of requests from the beginning of the queue. """ - request_params = self._params(limit=limit, clientKey=self.client_key) + request_params = self._build_params(limit=limit, clientKey=self.client_key) - response = self.http_client.call( - url=self._url('head'), + response = self._http_client.call( + url=self._build_url('head'), method='GET', params=request_params, - timeout_secs=_SMALL_TIMEOUT, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return GetHeadResponse.model_validate(result).data - def list_and_lock_head(self, *, lock_secs: int, limit: int | None = None) -> dict: + def list_and_lock_head(self, *, lock_secs: int, limit: int | None = None) -> LockedRequestQueueHead: """Retrieve a given number of unlocked requests from the beginning of the queue and lock them for a given time. https://docs.apify.com/api/v2#/reference/request-queues/queue-head-with-locks/get-head-and-lock @@ -133,18 +172,19 @@ def list_and_lock_head(self, *, lock_secs: int, limit: int | None = None) -> dic Returns: The desired number of locked requests from the beginning of the queue. """ - request_params = self._params(lockSecs=lock_secs, limit=limit, clientKey=self.client_key) + request_params = self._build_params(lockSecs=lock_secs, limit=limit, clientKey=self.client_key) - response = self.http_client.call( - url=self._url('head/lock'), + response = self._http_client.call( + url=self._build_url('head/lock'), method='POST', params=request_params, - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return GetHeadAndLockResponse.model_validate(result).data - def add_request(self, request: dict, *, forefront: bool | None = None) -> dict: + def add_request(self, request: dict, *, forefront: bool | None = None) -> RequestRegistration: """Add a request to the queue. https://docs.apify.com/api/v2#/reference/request-queues/request-collection/add-request @@ -156,19 +196,20 @@ def add_request(self, request: dict, *, forefront: bool | None = None) -> dict: Returns: The added request. """ - request_params = self._params(forefront=forefront, clientKey=self.client_key) + request_params = self._build_params(forefront=forefront, clientKey=self.client_key) - response = self.http_client.call( - url=self._url('requests'), + response = self._http_client.call( + url=self._build_url('requests'), method='POST', json=request, params=request_params, - timeout_secs=_SMALL_TIMEOUT, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return AddRequestResponse.model_validate(result).data - def get_request(self, request_id: str) -> dict | None: + def get_request(self, request_id: str) -> Request | None: """Retrieve a request from the queue. https://docs.apify.com/api/v2#/reference/request-queues/request/get-request @@ -180,20 +221,21 @@ def get_request(self, request_id: str) -> dict | None: The retrieved request, or None, if it did not exist. """ try: - response = self.http_client.call( - url=self._url(f'requests/{request_id}'), + response = self._http_client.call( + url=self._build_url(f'requests/{request_id}'), method='GET', - params=self._params(), - timeout_secs=_SMALL_TIMEOUT, + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return GetRequestResponse.model_validate(result).data except ApifyApiError as exc: catch_not_found_or_throw(exc) return None - def update_request(self, request: dict, *, forefront: bool | None = None) -> dict: + def update_request(self, request: dict, *, forefront: bool | None = None) -> RequestRegistration: """Update a request in the queue. https://docs.apify.com/api/v2#/reference/request-queues/request/update-request @@ -207,17 +249,18 @@ def update_request(self, request: dict, *, forefront: bool | None = None) -> dic """ request_id = request['id'] - request_params = self._params(forefront=forefront, clientKey=self.client_key) + request_params = self._build_params(forefront=forefront, clientKey=self.client_key) - response = self.http_client.call( - url=self._url(f'requests/{request_id}'), + response = self._http_client.call( + url=self._build_url(f'requests/{request_id}'), method='PUT', json=request, params=request_params, - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return AddRequestResponse.model_validate(result).data def delete_request(self, request_id: str) -> None: """Delete a request from the queue. @@ -227,15 +270,15 @@ def delete_request(self, request_id: str) -> None: Args: request_id: ID of the request to delete. """ - request_params = self._params( + request_params = self._build_params( clientKey=self.client_key, ) - self.http_client.call( - url=self._url(f'requests/{request_id}'), + self._http_client.call( + url=self._build_url(f'requests/{request_id}'), method='DELETE', params=request_params, - timeout_secs=_SMALL_TIMEOUT, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) def prolong_request_lock( @@ -244,7 +287,7 @@ def prolong_request_lock( *, forefront: bool | None = None, lock_secs: int, - ) -> dict: + ) -> RequestLockInfo | None: """Prolong the lock on a request. https://docs.apify.com/api/v2#/reference/request-queues/request-lock/prolong-request-lock @@ -254,16 +297,17 @@ def prolong_request_lock( forefront: Whether to put the request in the beginning or the end of the queue after lock expires. lock_secs: By how much to prolong the lock, in seconds. """ - request_params = self._params(clientKey=self.client_key, forefront=forefront, lockSecs=lock_secs) + request_params = self._build_params(clientKey=self.client_key, forefront=forefront, lockSecs=lock_secs) - response = self.http_client.call( - url=self._url(f'requests/{request_id}/lock'), + response = self._http_client.call( + url=self._build_url(f'requests/{request_id}/lock'), method='PUT', params=request_params, - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return ProlongRequestLockResponse.model_validate(result).data def delete_request_lock(self, request_id: str, *, forefront: bool | None = None) -> None: """Delete the lock on a request. @@ -274,13 +318,13 @@ def delete_request_lock(self, request_id: str, *, forefront: bool | None = None) request_id: ID of the request to delete the lock. forefront: Whether to put the request in the beginning or the end of the queue after the lock is deleted. """ - request_params = self._params(clientKey=self.client_key, forefront=forefront) + request_params = self._build_params(clientKey=self.client_key, forefront=forefront) - self.http_client.call( - url=self._url(f'requests/{request_id}/lock'), + self._http_client.call( + url=self._build_url(f'requests/{request_id}/lock'), method='DELETE', params=request_params, - timeout_secs=_SMALL_TIMEOUT, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) def batch_add_requests( @@ -291,7 +335,7 @@ def batch_add_requests( max_parallel: int = 1, max_unprocessed_requests_retries: int | None = None, min_delay_between_unprocessed_requests_retries: timedelta | None = None, - ) -> BatchAddRequestsResult: + ) -> BatchAddResult: """Add requests to the request queue in batches. Requests are split into batches based on size and processed in parallel. @@ -318,7 +362,7 @@ def batch_add_requests( if max_parallel != 1: raise NotImplementedError('max_parallel is only supported in async client') - request_params = self._params(clientKey=self.client_key, forefront=forefront) + request_params = self._build_params(clientKey=self.client_key, forefront=forefront) # Compute the payload size limit to ensure it doesn't exceed the maximum allowed size. payload_size_limit_bytes = _MAX_PAYLOAD_SIZE_BYTES - math.ceil(_MAX_PAYLOAD_SIZE_BYTES * _SAFETY_BUFFER_PERCENT) @@ -336,32 +380,35 @@ def batch_add_requests( for batch in batches: queue.put(batch) - processed_requests = list[dict]() - unprocessed_requests = list[dict]() + processed_requests = list[AddedRequest]() + unprocessed_requests = list[RequestDraft]() # Process all batches in the queue sequentially. while not queue.empty(): request_batch = queue.get() # Send the batch to the API. - response = self.http_client.call( - url=self._url('requests/batch'), + response = self._http_client.call( + url=self._build_url('requests/batch'), method='POST', params=request_params, json=list(request_batch), - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - response_parsed = parse_date_fields(pluck_data(response.json())) - processed_requests.extend(response_parsed.get('processedRequests', [])) - unprocessed_requests.extend(response_parsed.get('unprocessedRequests', [])) + response_parsed = response.json() + batch_response = BatchAddResponse.model_validate(response_parsed) + processed_requests.extend(batch_response.data.processed_requests) + unprocessed_requests.extend(batch_response.data.unprocessed_requests) - return { - 'processedRequests': processed_requests, - 'unprocessedRequests': unprocessed_requests, - } + return BatchAddResponse.model_construct( + data=BatchAddResult.model_construct( + processed_requests=processed_requests, + unprocessed_requests=unprocessed_requests, + ) + ).data - def batch_delete_requests(self, requests: list[dict]) -> dict: + def batch_delete_requests(self, requests: list[dict]) -> BatchDeleteResult: """Delete given requests from the queue. https://docs.apify.com/api/v2#/reference/request-queues/batch-request-operations/delete-requests @@ -369,24 +416,25 @@ def batch_delete_requests(self, requests: list[dict]) -> dict: Args: requests: List of the requests to delete. """ - request_params = self._params(clientKey=self.client_key) + request_params = self._build_params(clientKey=self.client_key) - response = self.http_client.call( - url=self._url('requests/batch'), + response = self._http_client.call( + url=self._build_url('requests/batch'), method='DELETE', params=request_params, json=requests, - timeout_secs=_SMALL_TIMEOUT, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return BatchDeleteResponse.model_validate(result).data def list_requests( self, *, limit: int | None = None, exclusive_start_id: str | None = None, - ) -> dict: + ) -> ListOfRequests: """List requests in the queue. https://docs.apify.com/api/v2#/reference/request-queues/request-collection/list-requests @@ -395,34 +443,36 @@ def list_requests( limit: How many requests to retrieve. exclusive_start_id: All requests up to this one (including) are skipped from the result. """ - request_params = self._params(limit=limit, exclusiveStartId=exclusive_start_id, clientKey=self.client_key) + request_params = self._build_params(limit=limit, exclusiveStartId=exclusive_start_id, clientKey=self.client_key) - response = self.http_client.call( - url=self._url('requests'), + response = self._http_client.call( + url=self._build_url('requests'), method='GET', params=request_params, - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return GetListOfRequestsResponse.model_validate(result).data - def unlock_requests(self: RequestQueueClient) -> dict: + def unlock_requests(self: RequestQueueClient) -> UnlockRequestsResult: """Unlock all requests in the queue, which were locked by the same clientKey or from the same Actor run. https://docs.apify.com/api/v2#/reference/request-queues/request-collection/unlock-requests Returns: - dict: Result of the unlock operation + Result of the unlock operation containing the count of unlocked requests """ - request_params = self._params(clientKey=self.client_key) + request_params = self._build_params(clientKey=self.client_key) - response = self.http_client.call( - url=self._url('requests/unlock'), + response = self._http_client.call( + url=self._build_url('requests/unlock'), method='POST', params=request_params, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return UnlockRequestsResponse.model_validate(result).data class RequestQueueClientAsync(ResourceClientAsync): @@ -443,7 +493,7 @@ def __init__( # noqa: D417 super().__init__(*args, resource_path=resource_path, **kwargs) self.client_key = client_key - async def get(self) -> dict | None: + async def get(self) -> RequestQueue | None: """Retrieve the request queue. https://docs.apify.com/api/v2#/reference/request-queues/queue/get-request-queue @@ -451,9 +501,25 @@ async def get(self) -> dict | None: Returns: The retrieved request queue, or None, if it does not exist. """ - return await self._get(timeout_secs=_SMALL_TIMEOUT) + try: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + result = response_to_dict(response) + return GetRequestQueueResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None - async def update(self, *, name: str | None = None, general_access: StorageGeneralAccess | None = None) -> dict: + async def update( + self, + *, + name: str | None = None, + general_access: StorageGeneralAccess | None = None, + ) -> RequestQueue: """Update the request queue with specified fields. https://docs.apify.com/api/v2#/reference/request-queues/queue/update-request-queue @@ -469,17 +535,34 @@ async def update(self, *, name: str | None = None, general_access: StorageGenera 'name': name, 'generalAccess': general_access, } + cleaned = filter_none_values(updated_fields) - return await self._update(filter_out_none_values_recursively(updated_fields), timeout_secs=_SMALL_TIMEOUT) + response = await self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + result = response_to_dict(response) + return GetRequestQueueResponse.model_validate(result).data async def delete(self) -> None: """Delete the request queue. https://docs.apify.com/api/v2#/reference/request-queues/queue/delete-request-queue """ - return await self._delete(timeout_secs=_SMALL_TIMEOUT) + try: + await self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) - async def list_head(self, *, limit: int | None = None) -> dict: + async def list_head(self, *, limit: int | None = None) -> RequestQueueHead: """Retrieve a given number of requests from the beginning of the queue. https://docs.apify.com/api/v2#/reference/request-queues/queue-head/get-head @@ -490,18 +573,19 @@ async def list_head(self, *, limit: int | None = None) -> dict: Returns: The desired number of requests from the beginning of the queue. """ - request_params = self._params(limit=limit, clientKey=self.client_key) + request_params = self._build_params(limit=limit, clientKey=self.client_key) - response = await self.http_client.call( - url=self._url('head'), + response = await self._http_client.call( + url=self._build_url('head'), method='GET', params=request_params, - timeout_secs=_SMALL_TIMEOUT, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return GetHeadResponse.model_validate(result).data - async def list_and_lock_head(self, *, lock_secs: int, limit: int | None = None) -> dict: + async def list_and_lock_head(self, *, lock_secs: int, limit: int | None = None) -> LockedRequestQueueHead: """Retrieve a given number of unlocked requests from the beginning of the queue and lock them for a given time. https://docs.apify.com/api/v2#/reference/request-queues/queue-head-with-locks/get-head-and-lock @@ -513,18 +597,19 @@ async def list_and_lock_head(self, *, lock_secs: int, limit: int | None = None) Returns: The desired number of locked requests from the beginning of the queue. """ - request_params = self._params(lockSecs=lock_secs, limit=limit, clientKey=self.client_key) + request_params = self._build_params(lockSecs=lock_secs, limit=limit, clientKey=self.client_key) - response = await self.http_client.call( - url=self._url('head/lock'), + response = await self._http_client.call( + url=self._build_url('head/lock'), method='POST', params=request_params, - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return GetHeadAndLockResponse.model_validate(result).data - async def add_request(self, request: dict, *, forefront: bool | None = None) -> dict: + async def add_request(self, request: dict, *, forefront: bool | None = None) -> RequestRegistration: """Add a request to the queue. https://docs.apify.com/api/v2#/reference/request-queues/request-collection/add-request @@ -536,19 +621,20 @@ async def add_request(self, request: dict, *, forefront: bool | None = None) -> Returns: The added request. """ - request_params = self._params(forefront=forefront, clientKey=self.client_key) + request_params = self._build_params(forefront=forefront, clientKey=self.client_key) - response = await self.http_client.call( - url=self._url('requests'), + response = await self._http_client.call( + url=self._build_url('requests'), method='POST', json=request, params=request_params, - timeout_secs=_SMALL_TIMEOUT, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return AddRequestResponse.model_validate(result).data - async def get_request(self, request_id: str) -> dict | None: + async def get_request(self, request_id: str) -> Request | None: """Retrieve a request from the queue. https://docs.apify.com/api/v2#/reference/request-queues/request/get-request @@ -560,20 +646,21 @@ async def get_request(self, request_id: str) -> dict | None: The retrieved request, or None, if it did not exist. """ try: - response = await self.http_client.call( - url=self._url(f'requests/{request_id}'), + response = await self._http_client.call( + url=self._build_url(f'requests/{request_id}'), method='GET', - params=self._params(), - timeout_secs=_SMALL_TIMEOUT, + params=self._build_params(), + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) - + result = response.json() + validated_response = GetRequestResponse.model_validate(result) if result is not None else None except ApifyApiError as exc: catch_not_found_or_throw(exc) + return None + else: + return validated_response.data if validated_response is not None else None - return None - - async def update_request(self, request: dict, *, forefront: bool | None = None) -> dict: + async def update_request(self, request: dict, *, forefront: bool | None = None) -> RequestRegistration: """Update a request in the queue. https://docs.apify.com/api/v2#/reference/request-queues/request/update-request @@ -587,17 +674,18 @@ async def update_request(self, request: dict, *, forefront: bool | None = None) """ request_id = request['id'] - request_params = self._params(forefront=forefront, clientKey=self.client_key) + request_params = self._build_params(forefront=forefront, clientKey=self.client_key) - response = await self.http_client.call( - url=self._url(f'requests/{request_id}'), + response = await self._http_client.call( + url=self._build_url(f'requests/{request_id}'), method='PUT', json=request, params=request_params, - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return AddRequestResponse.model_validate(result).data async def delete_request(self, request_id: str) -> None: """Delete a request from the queue. @@ -607,13 +695,13 @@ async def delete_request(self, request_id: str) -> None: Args: request_id: ID of the request to delete. """ - request_params = self._params(clientKey=self.client_key) + request_params = self._build_params(clientKey=self.client_key) - await self.http_client.call( - url=self._url(f'requests/{request_id}'), + await self._http_client.call( + url=self._build_url(f'requests/{request_id}'), method='DELETE', params=request_params, - timeout_secs=_SMALL_TIMEOUT, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) async def prolong_request_lock( @@ -622,7 +710,7 @@ async def prolong_request_lock( *, forefront: bool | None = None, lock_secs: int, - ) -> dict: + ) -> RequestLockInfo | None: """Prolong the lock on a request. https://docs.apify.com/api/v2#/reference/request-queues/request-lock/prolong-request-lock @@ -632,16 +720,17 @@ async def prolong_request_lock( forefront: Whether to put the request in the beginning or the end of the queue after lock expires. lock_secs: By how much to prolong the lock, in seconds. """ - request_params = self._params(clientKey=self.client_key, forefront=forefront, lockSecs=lock_secs) + request_params = self._build_params(clientKey=self.client_key, forefront=forefront, lockSecs=lock_secs) - response = await self.http_client.call( - url=self._url(f'requests/{request_id}/lock'), + response = await self._http_client.call( + url=self._build_url(f'requests/{request_id}/lock'), method='PUT', params=request_params, - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return ProlongRequestLockResponse.model_validate(result).data async def delete_request_lock( self, @@ -657,28 +746,28 @@ async def delete_request_lock( request_id: ID of the request to delete the lock. forefront: Whether to put the request in the beginning or the end of the queue after the lock is deleted. """ - request_params = self._params(clientKey=self.client_key, forefront=forefront) + request_params = self._build_params(clientKey=self.client_key, forefront=forefront) - await self.http_client.call( - url=self._url(f'requests/{request_id}/lock'), + await self._http_client.call( + url=self._build_url(f'requests/{request_id}/lock'), method='DELETE', params=request_params, - timeout_secs=_SMALL_TIMEOUT, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) async def _batch_add_requests_worker( self, queue: asyncio.Queue[Iterable[dict]], request_params: dict, - ) -> BatchAddRequestsResult: + ) -> BatchAddResponse: """Worker function to process a batch of requests. This worker will process batches from the queue. Return result containing lists of processed and unprocessed requests by the worker. """ - processed_requests = list[dict]() - unprocessed_requests = list[dict]() + processed_requests = list[AddedRequest]() + unprocessed_requests = list[RequestDraft]() while True: # Get the next batch from the queue. @@ -689,26 +778,29 @@ async def _batch_add_requests_worker( try: # Send the batch to the API. - response = await self.http_client.call( - url=self._url('requests/batch'), + response = await self._http_client.call( + url=self._build_url('requests/batch'), method='POST', params=request_params, json=list(request_batch), - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - response_parsed = parse_date_fields(pluck_data(response.json())) - processed_requests.extend(response_parsed.get('processedRequests', [])) - unprocessed_requests.extend(response_parsed.get('unprocessedRequests', [])) + response_parsed = response.json() + batch_response = BatchAddResponse.model_validate(response_parsed) + processed_requests.extend(batch_response.data.processed_requests) + unprocessed_requests.extend(batch_response.data.unprocessed_requests) finally: # Mark the batch as done whether it succeeded or failed. queue.task_done() - return { - 'processedRequests': processed_requests, - 'unprocessedRequests': unprocessed_requests, - } + return BatchAddResponse.model_construct( + data=BatchAddResult.model_construct( + processed_requests=processed_requests, + unprocessed_requests=unprocessed_requests, + ) + ) async def batch_add_requests( self, @@ -718,7 +810,7 @@ async def batch_add_requests( max_parallel: int = 5, max_unprocessed_requests_retries: int | None = None, min_delay_between_unprocessed_requests_retries: timedelta | None = None, - ) -> BatchAddRequestsResult: + ) -> BatchAddResult: """Add requests to the request queue in batches. Requests are split into batches based on size and processed in parallel. @@ -743,8 +835,8 @@ async def batch_add_requests( logger.warning('`min_delay_between_unprocessed_requests_retries` is deprecated and not used anymore.') tasks = set[asyncio.Task]() - queue: asyncio.Queue[Iterable[dict]] = asyncio.Queue() - request_params = self._params(clientKey=self.client_key, forefront=forefront) + asyncio_queue: asyncio.Queue[Iterable[dict]] = asyncio.Queue() + request_params = self._build_params(clientKey=self.client_key, forefront=forefront) # Compute the payload size limit to ensure it doesn't exceed the maximum allowed size. payload_size_limit_bytes = _MAX_PAYLOAD_SIZE_BYTES - math.ceil(_MAX_PAYLOAD_SIZE_BYTES * _SAFETY_BUFFER_PERCENT) @@ -757,40 +849,42 @@ async def batch_add_requests( ) for batch in batches: - await queue.put(batch) + await asyncio_queue.put(batch) # Start a required number of worker tasks to process the batches. for i in range(max_parallel): coro = self._batch_add_requests_worker( - queue, + asyncio_queue, request_params, ) task = asyncio.create_task(coro, name=f'batch_add_requests_worker_{i}') tasks.add(task) # Wait for all batches to be processed. - await queue.join() + await asyncio_queue.join() # Send cancellation signals to all worker tasks and wait for them to finish. for task in tasks: task.cancel() - results: list[BatchAddRequestsResult] = await asyncio.gather(*tasks) + results: list[BatchAddResponse] = await asyncio.gather(*tasks) # Combine the results from all workers and return them. - processed_requests = [] - unprocessed_requests = [] + processed_requests = list[AddedRequest]() + unprocessed_requests = list[RequestDraft]() for result in results: - processed_requests.extend(result['processedRequests']) - unprocessed_requests.extend(result['unprocessedRequests']) + processed_requests.extend(result.data.processed_requests) + unprocessed_requests.extend(result.data.unprocessed_requests) - return { - 'processedRequests': processed_requests, - 'unprocessedRequests': unprocessed_requests, - } + return BatchAddResponse.model_construct( + data=BatchAddResult.model_construct( + processed_requests=processed_requests, + unprocessed_requests=unprocessed_requests, + ) + ).data - async def batch_delete_requests(self, requests: list[dict]) -> dict: + async def batch_delete_requests(self, requests: list[dict]) -> BatchDeleteResult: """Delete given requests from the queue. https://docs.apify.com/api/v2#/reference/request-queues/batch-request-operations/delete-requests @@ -798,23 +892,24 @@ async def batch_delete_requests(self, requests: list[dict]) -> dict: Args: requests: List of the requests to delete. """ - request_params = self._params(clientKey=self.client_key) + request_params = self._build_params(clientKey=self.client_key) - response = await self.http_client.call( - url=self._url('requests/batch'), + response = await self._http_client.call( + url=self._build_url('requests/batch'), method='DELETE', params=request_params, json=requests, - timeout_secs=_SMALL_TIMEOUT, + timeout_secs=FAST_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return BatchDeleteResponse.model_validate(result).data async def list_requests( self, *, limit: int | None = None, exclusive_start_id: str | None = None, - ) -> dict: + ) -> ListOfRequests: """List requests in the queue. https://docs.apify.com/api/v2#/reference/request-queues/request-collection/list-requests @@ -823,31 +918,33 @@ async def list_requests( limit: How many requests to retrieve. exclusive_start_id: All requests up to this one (including) are skipped from the result. """ - request_params = self._params(limit=limit, exclusiveStartId=exclusive_start_id, clientKey=self.client_key) + request_params = self._build_params(limit=limit, exclusiveStartId=exclusive_start_id, clientKey=self.client_key) - response = await self.http_client.call( - url=self._url('requests'), + response = await self._http_client.call( + url=self._build_url('requests'), method='GET', params=request_params, - timeout_secs=_MEDIUM_TIMEOUT, + timeout_secs=STANDARD_OPERATION_TIMEOUT_SECS, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return GetListOfRequestsResponse.model_validate(result).data - async def unlock_requests(self: RequestQueueClientAsync) -> dict: + async def unlock_requests(self: RequestQueueClientAsync) -> UnlockRequestsResult: """Unlock all requests in the queue, which were locked by the same clientKey or from the same Actor run. https://docs.apify.com/api/v2#/reference/request-queues/request-collection/unlock-requests Returns: - dict: Result of the unlock operation + Result of the unlock operation containing the count of unlocked requests """ - request_params = self._params(clientKey=self.client_key) + request_params = self._build_params(clientKey=self.client_key) - response = await self.http_client.call( - url=self._url('requests/unlock'), + response = await self._http_client.call( + url=self._build_url('requests/unlock'), method='POST', params=request_params, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return UnlockRequestsResponse.model_validate(result).data diff --git a/src/apify_client/clients/resource_clients/request_queue_collection.py b/src/apify_client/_resource_clients/request_queue_collection.py similarity index 61% rename from src/apify_client/clients/resource_clients/request_queue_collection.py rename to src/apify_client/_resource_clients/request_queue_collection.py index f2ee80bb..36ecaf40 100644 --- a/src/apify_client/clients/resource_clients/request_queue_collection.py +++ b/src/apify_client/_resource_clients/request_queue_collection.py @@ -1,14 +1,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any -from apify_client.clients.base import ResourceCollectionClient, ResourceCollectionClientAsync +from apify_client._models import ( + CreateRequestQueueResponse, + GetListOfRequestQueuesResponse, + ListOfRequestQueues, + RequestQueue, +) +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils import response_to_dict -if TYPE_CHECKING: - from apify_client.clients.base.resource_collection_client import ListPage - -class RequestQueueCollectionClient(ResourceCollectionClient): +class RequestQueueCollectionClient(ResourceClient): """Sub-client for manipulating request queues.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -22,7 +26,7 @@ def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfRequestQueues: """List the available request queues. https://docs.apify.com/api/v2#/reference/request-queues/queue-collection/get-list-of-request-queues @@ -36,9 +40,15 @@ def list( Returns: The list of available request queues matching the specified filters. """ - return self._list(unnamed=unnamed, limit=limit, offset=offset, desc=desc) - - def get_or_create(self, *, name: str | None = None) -> dict: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(unnamed=unnamed, limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfRequestQueuesResponse.model_validate(response_as_dict).data + + def get_or_create(self, *, name: str | None = None) -> RequestQueue: """Retrieve a named request queue, or create a new one when it doesn't exist. https://docs.apify.com/api/v2#/reference/request-queues/queue-collection/create-request-queue @@ -49,10 +59,16 @@ def get_or_create(self, *, name: str | None = None) -> dict: Returns: The retrieved or newly-created request queue. """ - return self._get_or_create(name=name) + response = self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(name=name), + ) + result = response_to_dict(response) + return CreateRequestQueueResponse.model_validate(result).data -class RequestQueueCollectionClientAsync(ResourceCollectionClientAsync): +class RequestQueueCollectionClientAsync(ResourceClientAsync): """Async sub-client for manipulating request queues.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -66,7 +82,7 @@ async def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfRequestQueues: """List the available request queues. https://docs.apify.com/api/v2#/reference/request-queues/queue-collection/get-list-of-request-queues @@ -80,9 +96,15 @@ async def list( Returns: The list of available request queues matching the specified filters. """ - return await self._list(unnamed=unnamed, limit=limit, offset=offset, desc=desc) - - async def get_or_create(self, *, name: str | None = None) -> dict: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(unnamed=unnamed, limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfRequestQueuesResponse.model_validate(response_as_dict).data + + async def get_or_create(self, *, name: str | None = None) -> RequestQueue: """Retrieve a named request queue, or create a new one when it doesn't exist. https://docs.apify.com/api/v2#/reference/request-queues/queue-collection/create-request-queue @@ -93,4 +115,10 @@ async def get_or_create(self, *, name: str | None = None) -> dict: Returns: The retrieved or newly-created request queue. """ - return await self._get_or_create(name=name) + response = await self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(name=name), + ) + result = response_to_dict(response) + return CreateRequestQueueResponse.model_validate(result).data diff --git a/src/apify_client/clients/resource_clients/run.py b/src/apify_client/_resource_clients/run.py similarity index 65% rename from src/apify_client/clients/resource_clients/run.py rename to src/apify_client/_resource_clients/run.py index 7889709e..8f66729b 100644 --- a/src/apify_client/clients/resource_clients/run.py +++ b/src/apify_client/_resource_clients/run.py @@ -1,49 +1,60 @@ from __future__ import annotations import json -import logging import random import string import time from datetime import timedelta from typing import TYPE_CHECKING, Any +if TYPE_CHECKING: + import logging + from decimal import Decimal + + from apify_client._consts import RunGeneralAccess + from apify_client._resource_clients.dataset import DatasetClient, DatasetClientAsync + from apify_client._resource_clients.key_value_store import KeyValueStoreClient, KeyValueStoreClientAsync + from apify_client._resource_clients.log import ( + LogClient, + LogClientAsync, + StatusMessageWatcherAsync, + StatusMessageWatcherSync, + StreamedLogAsync, + StreamedLogSync, + ) + from apify_client._resource_clients.request_queue import RequestQueueClient, RequestQueueClientAsync + + from apify_client._logging import create_redirect_logger +from apify_client._models import GetRunResponse, Run +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync from apify_client._utils import ( + catch_not_found_or_throw, encode_key_value_store_record_value, - filter_out_none_values_recursively, - parse_date_fields, - pluck_data, + filter_none_values, + response_to_dict, to_safe_id, ) -from apify_client.clients.base import ActorJobBaseClient, ActorJobBaseClientAsync -from apify_client.clients.resource_clients.dataset import DatasetClient, DatasetClientAsync -from apify_client.clients.resource_clients.key_value_store import KeyValueStoreClient, KeyValueStoreClientAsync -from apify_client.clients.resource_clients.log import ( - LogClient, - LogClientAsync, - StatusMessageWatcherAsync, - StatusMessageWatcherSync, - StreamedLogAsync, - StreamedLogSync, -) -from apify_client.clients.resource_clients.request_queue import RequestQueueClient, RequestQueueClientAsync +from apify_client.errors import ApifyApiError -if TYPE_CHECKING: - import logging - from decimal import Decimal - from apify_shared.consts import RunGeneralAccess - - -class RunClient(ActorJobBaseClient): +class RunClient(ResourceClient): """Sub-client for manipulating a single Actor run.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: - resource_path = kwargs.pop('resource_path', 'actor-runs') - super().__init__(*args, resource_path=resource_path, **kwargs) + def __init__( + self, + *, + resource_id: str, + resource_path: str = 'actor-runs', + **kwargs: Any, + ) -> None: + super().__init__( + resource_id=resource_id, + resource_path=resource_path, + **kwargs, + ) - def get(self) -> dict | None: + def get(self) -> Run | None: """Return information about the Actor run. https://docs.apify.com/api/v2#/reference/actor-runs/run-object/get-run @@ -51,7 +62,17 @@ def get(self) -> dict | None: Returns: The retrieved Actor run data. """ - return self._get() + try: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetRunResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None def update( self, @@ -59,7 +80,7 @@ def update( status_message: str | None = None, is_status_message_terminal: bool | None = None, general_access: RunGeneralAccess | None = None, - ) -> dict: + ) -> Run: """Update the run with the specified fields. https://docs.apify.com/api/v2#/reference/actor-runs/run-object/update-run @@ -77,17 +98,32 @@ def update( 'isStatusMessageTerminal': is_status_message_terminal, 'generalAccess': general_access, } + cleaned = filter_none_values(updated_fields) - return self._update(filter_out_none_values_recursively(updated_fields)) + response = self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return GetRunResponse.model_validate(result).data def delete(self) -> None: """Delete the run. https://docs.apify.com/api/v2#/reference/actor-runs/delete-run/delete-run """ - return self._delete() - - def abort(self, *, gracefully: bool | None = None) -> dict: + try: + self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + + def abort(self, *, gracefully: bool | None = None) -> Run: """Abort the Actor run which is starting or currently running and return its details. https://docs.apify.com/api/v2#/reference/actor-runs/abort-run/abort-run @@ -100,9 +136,15 @@ def abort(self, *, gracefully: bool | None = None) -> dict: Returns: The data of the aborted Actor run. """ - return self._abort(gracefully=gracefully) + response = self._http_client.call( + url=self._build_url('abort'), + method='POST', + params=self._build_params(gracefully=gracefully), + ) + result = response_to_dict(response) + return GetRunResponse.model_validate(result).data - def wait_for_finish(self, *, wait_secs: int | None = None) -> dict | None: + def wait_for_finish(self, *, wait_secs: int | None = None) -> Run | None: """Wait synchronously until the run finishes or the server times out. Args: @@ -112,7 +154,16 @@ def wait_for_finish(self, *, wait_secs: int | None = None) -> dict | None: The Actor run data. If the status on the object is not one of the terminal statuses (SUCCEEDED, FAILED, TIMED_OUT, ABORTED), then the run has not yet finished. """ - return self._wait_for_finish(wait_secs=wait_secs) + response = self._wait_for_finish( + url=self._build_url(), + params=self._build_params(), + wait_secs=wait_secs, + ) + + if response is None: + return None + + return Run.model_validate(response) def metamorph( self, @@ -121,7 +172,7 @@ def metamorph( target_actor_build: str | None = None, run_input: Any = None, content_type: str | None = None, - ) -> dict: + ) -> Run: """Transform an Actor run into a run of another Actor with a new input. https://docs.apify.com/api/v2#/reference/actor-runs/metamorph-run/metamorph-run @@ -141,17 +192,18 @@ def metamorph( safe_target_actor_id = to_safe_id(target_actor_id) - request_params = self._params(targetActorId=safe_target_actor_id, build=target_actor_build) + request_params = self._build_params(targetActorId=safe_target_actor_id, build=target_actor_build) - response = self.http_client.call( - url=self._url('metamorph'), + response = self._http_client.call( + url=self._build_url('metamorph'), method='POST', headers={'content-type': content_type}, data=run_input, params=request_params, ) - return parse_date_fields(pluck_data(response.json())) + response_as_dict = response_to_dict(response) + return GetRunResponse.model_validate(response_as_dict).data def resurrect( self, @@ -162,7 +214,7 @@ def resurrect( max_items: int | None = None, max_total_charge_usd: Decimal | None = None, restart_on_error: bool | None = None, - ) -> dict: + ) -> Run: """Resurrect a finished Actor run. Only finished runs, i.e. runs with status FINISHED, FAILED, ABORTED and TIMED-OUT can be resurrected. @@ -187,7 +239,7 @@ def resurrect( Returns: The Actor run data. """ - request_params = self._params( + request_params = self._build_params( build=build, memory=memory_mbytes, timeout=timeout_secs, @@ -196,15 +248,16 @@ def resurrect( restartOnError=restart_on_error, ) - response = self.http_client.call( - url=self._url('resurrect'), + response = self._http_client.call( + url=self._build_url('resurrect'), method='POST', params=request_params, ) - return parse_date_fields(pluck_data(response.json())) + response_as_dict = response_to_dict(response) + return GetRunResponse.model_validate(response_as_dict).data - def reboot(self) -> dict: + def reboot(self) -> Run: """Reboot an Actor run. Only runs that are running, i.e. runs with status RUNNING can be rebooted. https://docs.apify.com/api/v2#/reference/actor-runs/reboot-run/reboot-run @@ -212,11 +265,12 @@ def reboot(self) -> dict: Returns: The Actor run data. """ - response = self.http_client.call( - url=self._url('reboot'), + response = self._http_client.call( + url=self._build_url('reboot'), method='POST', ) - return parse_date_fields(pluck_data(response.json())) + response_as_dict = response_to_dict(response) + return GetRunResponse.model_validate(response_as_dict).data def dataset(self) -> DatasetClient: """Get the client for the default dataset of the Actor run. @@ -226,8 +280,9 @@ def dataset(self) -> DatasetClient: Returns: A client allowing access to the default dataset of this Actor run. """ - return DatasetClient( - **self._sub_resource_init_options(resource_path='dataset'), + return self._client_registry.dataset_client( + resource_path='dataset', + **self._base_client_kwargs, ) def key_value_store(self) -> KeyValueStoreClient: @@ -238,8 +293,9 @@ def key_value_store(self) -> KeyValueStoreClient: Returns: A client allowing access to the default key-value store of this Actor run. """ - return KeyValueStoreClient( - **self._sub_resource_init_options(resource_path='key-value-store'), + return self._client_registry.key_value_store_client( + resource_path='key-value-store', + **self._base_client_kwargs, ) def request_queue(self) -> RequestQueueClient: @@ -250,8 +306,9 @@ def request_queue(self) -> RequestQueueClient: Returns: A client allowing access to the default request_queue of this Actor run. """ - return RequestQueueClient( - **self._sub_resource_init_options(resource_path='request-queue'), + return self._client_registry.request_queue_client( + resource_path='request-queue', + **self._base_client_kwargs, ) def log(self) -> LogClient: @@ -262,8 +319,9 @@ def log(self) -> LogClient: Returns: A client allowing access to the log of this Actor run. """ - return LogClient( - **self._sub_resource_init_options(resource_path='log'), + return self._client_registry.log_client( + resource_path='log', + **self._base_client_kwargs, ) def get_streamed_log(self, to_logger: logging.Logger | None = None, *, from_start: bool = True) -> StreamedLogSync: @@ -281,16 +339,28 @@ def get_streamed_log(self, to_logger: logging.Logger | None = None, *, from_star `StreamedLog` instance for redirected logs. """ run_data = self.get() - run_id = f'runId:{run_data.get("id", "")}' if run_data else '' - - actor_id = run_data.get('actId', '') if run_data else '' - actor_data = self.root_client.actor(actor_id=actor_id).get() or {} - actor_name = actor_data.get('name', '') if run_data else '' + run_id = f'runId:{run_data.id}' if run_data and run_data.id else '' + + actor_id = run_data.act_id if run_data else '' + actor_data = None + if actor_id: + actor_client = self._client_registry.actor_client( + resource_id=actor_id, + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + ) + actor_data = actor_client.get() + actor_name = actor_data.name if actor_data else '' if not to_logger: name = ' '.join(part for part in (actor_name, run_id) if part) to_logger = create_redirect_logger(f'apify.{name}') + # Import inline to avoid circular dependency with log.py + from apify_client._resource_clients.log import StreamedLogSync # noqa: PLC0415 + return StreamedLogSync(log_client=self.log(), to_logger=to_logger, from_start=from_start) def charge( @@ -311,11 +381,11 @@ def charge( idempotency_key = ( idempotency_key - or f'{self.resource_id}-{event_name}-{int(time.time() * 1000)}-{"".join(random.choices(string.ascii_letters + string.digits, k=6))}' # noqa: E501 + or f'{self._resource_id}-{event_name}-{int(time.time() * 1000)}-{"".join(random.choices(string.ascii_letters + string.digits, k=6))}' # noqa: E501 ) - self.http_client.call( - url=self._url('charge'), + self._http_client.call( + url=self._build_url('charge'), method='POST', headers={ 'idempotency-key': idempotency_key, @@ -345,27 +415,48 @@ def get_status_message_watcher( `StatusMessageWatcher` instance. """ run_data = self.get() - run_id = f'runId:{run_data.get("id", "")}' if run_data else '' - - actor_id = run_data.get('actId', '') if run_data else '' - actor_data = self.root_client.actor(actor_id=actor_id).get() or {} - actor_name = actor_data.get('name', '') if run_data else '' + run_id = f'runId:{run_data.id}' if run_data and run_data.id else '' + + actor_id = run_data.act_id if run_data else '' + actor_data = None + if actor_id: + actor_client = self._client_registry.actor_client( + resource_id=actor_id, + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + ) + actor_data = actor_client.get() + actor_name = actor_data.name if actor_data else '' if not to_logger: name = ' '.join(part for part in (actor_name, run_id) if part) to_logger = create_redirect_logger(f'apify.{name}') + # Import inline to avoid circular dependency with log.py + from apify_client._resource_clients.log import StatusMessageWatcherSync # noqa: PLC0415 + return StatusMessageWatcherSync(run_client=self, to_logger=to_logger, check_period=check_period) -class RunClientAsync(ActorJobBaseClientAsync): +class RunClientAsync(ResourceClientAsync): """Async sub-client for manipulating a single Actor run.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: - resource_path = kwargs.pop('resource_path', 'actor-runs') - super().__init__(*args, resource_path=resource_path, **kwargs) + def __init__( + self, + *, + resource_id: str, + resource_path: str = 'actor-runs', + **kwargs: Any, + ) -> None: + super().__init__( + resource_id=resource_id, + resource_path=resource_path, + **kwargs, + ) - async def get(self) -> dict | None: + async def get(self) -> Run | None: """Return information about the Actor run. https://docs.apify.com/api/v2#/reference/actor-runs/run-object/get-run @@ -373,7 +464,17 @@ async def get(self) -> dict | None: Returns: The retrieved Actor run data. """ - return await self._get() + try: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetRunResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None async def update( self, @@ -381,7 +482,7 @@ async def update( status_message: str | None = None, is_status_message_terminal: bool | None = None, general_access: RunGeneralAccess | None = None, - ) -> dict: + ) -> Run: """Update the run with the specified fields. https://docs.apify.com/api/v2#/reference/actor-runs/run-object/update-run @@ -399,10 +500,18 @@ async def update( 'isStatusMessageTerminal': is_status_message_terminal, 'generalAccess': general_access, } + cleaned = filter_none_values(updated_fields) - return await self._update(filter_out_none_values_recursively(updated_fields)) + response = await self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return GetRunResponse.model_validate(result).data - async def abort(self, *, gracefully: bool | None = None) -> dict: + async def abort(self, *, gracefully: bool | None = None) -> Run: """Abort the Actor run which is starting or currently running and return its details. https://docs.apify.com/api/v2#/reference/actor-runs/abort-run/abort-run @@ -415,9 +524,15 @@ async def abort(self, *, gracefully: bool | None = None) -> dict: Returns: The data of the aborted Actor run. """ - return await self._abort(gracefully=gracefully) + response = await self._http_client.call( + url=self._build_url('abort'), + method='POST', + params=self._build_params(gracefully=gracefully), + ) + result = response_to_dict(response) + return GetRunResponse.model_validate(result).data - async def wait_for_finish(self, *, wait_secs: int | None = None) -> dict | None: + async def wait_for_finish(self, *, wait_secs: int | None = None) -> Run | None: """Wait synchronously until the run finishes or the server times out. Args: @@ -427,14 +542,26 @@ async def wait_for_finish(self, *, wait_secs: int | None = None) -> dict | None: The Actor run data. If the status on the object is not one of the terminal statuses (SUCCEEDED, FAILED, TIMED_OUT, ABORTED), then the run has not yet finished. """ - return await self._wait_for_finish(wait_secs=wait_secs) + response = await self._wait_for_finish( + url=self._build_url(), + params=self._build_params(), + wait_secs=wait_secs, + ) + return Run.model_validate(response) if response is not None else None async def delete(self) -> None: """Delete the run. https://docs.apify.com/api/v2#/reference/actor-runs/delete-run/delete-run """ - return await self._delete() + try: + await self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) async def metamorph( self, @@ -443,7 +570,7 @@ async def metamorph( target_actor_build: str | None = None, run_input: Any = None, content_type: str | None = None, - ) -> dict: + ) -> Run: """Transform an Actor run into a run of another Actor with a new input. https://docs.apify.com/api/v2#/reference/actor-runs/metamorph-run/metamorph-run @@ -463,20 +590,21 @@ async def metamorph( safe_target_actor_id = to_safe_id(target_actor_id) - request_params = self._params( + request_params = self._build_params( targetActorId=safe_target_actor_id, build=target_actor_build, ) - response = await self.http_client.call( - url=self._url('metamorph'), + response = await self._http_client.call( + url=self._build_url('metamorph'), method='POST', headers={'content-type': content_type}, data=run_input, params=request_params, ) - return parse_date_fields(pluck_data(response.json())) + response_as_dict = response_to_dict(response) + return GetRunResponse.model_validate(response_as_dict).data async def resurrect( self, @@ -487,7 +615,7 @@ async def resurrect( max_items: int | None = None, max_total_charge_usd: Decimal | None = None, restart_on_error: bool | None = None, - ) -> dict: + ) -> Run: """Resurrect a finished Actor run. Only finished runs, i.e. runs with status FINISHED, FAILED, ABORTED and TIMED-OUT can be resurrected. @@ -512,7 +640,7 @@ async def resurrect( Returns: The Actor run data. """ - request_params = self._params( + request_params = self._build_params( build=build, memory=memory_mbytes, timeout=timeout_secs, @@ -521,15 +649,16 @@ async def resurrect( restartOnError=restart_on_error, ) - response = await self.http_client.call( - url=self._url('resurrect'), + response = await self._http_client.call( + url=self._build_url('resurrect'), method='POST', params=request_params, ) - return parse_date_fields(pluck_data(response.json())) + response_as_dict = response_to_dict(response) + return GetRunResponse.model_validate(response_as_dict).data - async def reboot(self) -> dict: + async def reboot(self) -> Run: """Reboot an Actor run. Only runs that are running, i.e. runs with status RUNNING can be rebooted. https://docs.apify.com/api/v2#/reference/actor-runs/reboot-run/reboot-run @@ -537,11 +666,12 @@ async def reboot(self) -> dict: Returns: The Actor run data. """ - response = await self.http_client.call( - url=self._url('reboot'), + response = await self._http_client.call( + url=self._build_url('reboot'), method='POST', ) - return parse_date_fields(pluck_data(response.json())) + response_as_dict = response_to_dict(response) + return GetRunResponse.model_validate(response_as_dict).data def dataset(self) -> DatasetClientAsync: """Get the client for the default dataset of the Actor run. @@ -551,8 +681,9 @@ def dataset(self) -> DatasetClientAsync: Returns: A client allowing access to the default dataset of this Actor run. """ - return DatasetClientAsync( - **self._sub_resource_init_options(resource_path='dataset'), + return self._client_registry.dataset_client( + resource_path='dataset', + **self._base_client_kwargs, ) def key_value_store(self) -> KeyValueStoreClientAsync: @@ -563,8 +694,9 @@ def key_value_store(self) -> KeyValueStoreClientAsync: Returns: A client allowing access to the default key-value store of this Actor run. """ - return KeyValueStoreClientAsync( - **self._sub_resource_init_options(resource_path='key-value-store'), + return self._client_registry.key_value_store_client( + resource_path='key-value-store', + **self._base_client_kwargs, ) def request_queue(self) -> RequestQueueClientAsync: @@ -575,8 +707,9 @@ def request_queue(self) -> RequestQueueClientAsync: Returns: A client allowing access to the default request_queue of this Actor run. """ - return RequestQueueClientAsync( - **self._sub_resource_init_options(resource_path='request-queue'), + return self._client_registry.request_queue_client( + resource_path='request-queue', + **self._base_client_kwargs, ) def log(self) -> LogClientAsync: @@ -587,8 +720,9 @@ def log(self) -> LogClientAsync: Returns: A client allowing access to the log of this Actor run. """ - return LogClientAsync( - **self._sub_resource_init_options(resource_path='log'), + return self._client_registry.log_client( + resource_path='log', + **self._base_client_kwargs, ) async def get_streamed_log( @@ -608,16 +742,28 @@ async def get_streamed_log( `StreamedLog` instance for redirected logs. """ run_data = await self.get() - run_id = f'runId:{run_data.get("id", "")}' if run_data else '' - - actor_id = run_data.get('actId', '') if run_data else '' - actor_data = await self.root_client.actor(actor_id=actor_id).get() or {} - actor_name = actor_data.get('name', '') if run_data else '' + run_id = f'runId:{run_data.id}' if run_data and run_data.id else '' + + actor_id = run_data.act_id if run_data else '' + actor_data = None + if actor_id: + actor_client = self._client_registry.actor_client( + resource_id=actor_id, + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + ) + actor_data = await actor_client.get() + actor_name = actor_data.name if actor_data else '' if not to_logger: name = ' '.join(part for part in (actor_name, run_id) if part) to_logger = create_redirect_logger(f'apify.{name}') + # Import inline to avoid circular dependency with log.py + from apify_client._resource_clients.log import StreamedLogAsync # noqa: PLC0415 + return StreamedLogAsync(log_client=self.log(), to_logger=to_logger, from_start=from_start) async def charge( @@ -637,11 +783,11 @@ async def charge( raise ValueError('event_name is required for charging an event') idempotency_key = idempotency_key or ( - f'{self.resource_id}-{event_name}-{int(time.time() * 1000)}-{"".join(random.choices(string.ascii_letters + string.digits, k=6))}' # noqa: E501 + f'{self._resource_id}-{event_name}-{int(time.time() * 1000)}-{"".join(random.choices(string.ascii_letters + string.digits, k=6))}' # noqa: E501 ) - await self.http_client.call( - url=self._url('charge'), + await self._http_client.call( + url=self._build_url('charge'), method='POST', headers={ 'idempotency-key': idempotency_key, @@ -673,14 +819,27 @@ async def get_status_message_watcher( `StatusMessageWatcher` instance. """ run_data = await self.get() - run_id = f'runId:{run_data.get("id", "")}' if run_data else '' - actor_id = run_data.get('actId', '') if run_data else '' - actor_data = await self.root_client.actor(actor_id=actor_id).get() or {} - actor_name = actor_data.get('name', '') if run_data else '' + run_id = f'runId:{run_data.id}' if run_data and run_data.id else '' + + actor_id = run_data.act_id if run_data else '' + actor_data = None + if actor_id: + actor_client = self._client_registry.actor_client( + resource_id=actor_id, + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + ) + actor_data = await actor_client.get() + actor_name = actor_data.name if actor_data else '' if not to_logger: name = ' '.join(part for part in (actor_name, run_id) if part) to_logger = create_redirect_logger(f'apify.{name}') + # Import inline to avoid circular dependency with log.py + from apify_client._resource_clients.log import StatusMessageWatcherAsync # noqa: PLC0415 + return StatusMessageWatcherAsync(run_client=self, to_logger=to_logger, check_period=check_period) diff --git a/src/apify_client/clients/resource_clients/run_collection.py b/src/apify_client/_resource_clients/run_collection.py similarity index 65% rename from src/apify_client/clients/resource_clients/run_collection.py rename to src/apify_client/_resource_clients/run_collection.py index b51d5c47..95240f3d 100644 --- a/src/apify_client/clients/resource_clients/run_collection.py +++ b/src/apify_client/_resource_clients/run_collection.py @@ -2,18 +2,17 @@ from typing import TYPE_CHECKING, Any -from apify_client._utils import maybe_extract_enum_member_value -from apify_client.clients.base import ResourceCollectionClient, ResourceCollectionClientAsync +from apify_client._models import GetListOfRunsResponse, ListOfRuns +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils import enum_to_value, response_to_dict if TYPE_CHECKING: from datetime import datetime - from apify_shared.consts import ActorJobStatus + from apify_client._consts import ActorJobStatus - from apify_client.clients.base.resource_collection_client import ListPage - -class RunCollectionClient(ResourceCollectionClient): +class RunCollectionClient(ResourceClient): """Sub-client for listing Actor runs.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -29,7 +28,7 @@ def list( status: ActorJobStatus | list[ActorJobStatus] | None = None, # ty: ignore[invalid-type-form] started_before: str | datetime | None = None, started_after: str | datetime | None = None, - ) -> ListPage[dict]: + ) -> ListOfRuns: """List all Actor runs. List all Actor runs, either of a single Actor, or all user's Actors, depending on where this client @@ -49,22 +48,25 @@ def list( Returns: The retrieved Actor runs. """ - if isinstance(status, list): - status_param = [maybe_extract_enum_member_value(s) for s in status] - else: - status_param = maybe_extract_enum_member_value(status) - - return self._list( - limit=limit, - offset=offset, - desc=desc, - status=status_param, - startedBefore=started_before, - startedAfter=started_after, + status_param = [enum_to_value(s) for s in status] if isinstance(status, list) else enum_to_value(status) + + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params( + limit=limit, + offset=offset, + desc=desc, + status=status_param, + startedBefore=started_before, + startedAfter=started_after, + ), ) + response_as_dict = response_to_dict(response) + return GetListOfRunsResponse.model_validate(response_as_dict).data -class RunCollectionClientAsync(ResourceCollectionClientAsync): +class RunCollectionClientAsync(ResourceClientAsync): """Async sub-client for listing Actor runs.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -80,7 +82,7 @@ async def list( status: ActorJobStatus | list[ActorJobStatus] | None = None, # ty: ignore[invalid-type-form] started_before: str | datetime | None = None, started_after: str | datetime | None = None, - ) -> ListPage[dict]: + ) -> ListOfRuns: """List all Actor runs. List all Actor runs, either of a single Actor, or all user's Actors, depending on where this client @@ -100,16 +102,19 @@ async def list( Returns: The retrieved Actor runs. """ - if isinstance(status, list): - status_param = [maybe_extract_enum_member_value(s) for s in status] - else: - status_param = maybe_extract_enum_member_value(status) - - return await self._list( - limit=limit, - offset=offset, - desc=desc, - status=status_param, - startedBefore=started_before, - startedAfter=started_after, + status_param = [enum_to_value(s) for s in status] if isinstance(status, list) else enum_to_value(status) + + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params( + limit=limit, + offset=offset, + desc=desc, + status=status_param, + startedBefore=started_before, + startedAfter=started_after, + ), ) + response_as_dict = response_to_dict(response) + return GetListOfRunsResponse.model_validate(response_as_dict).data diff --git a/src/apify_client/clients/resource_clients/schedule.py b/src/apify_client/_resource_clients/schedule.py similarity index 65% rename from src/apify_client/clients/resource_clients/schedule.py rename to src/apify_client/_resource_clients/schedule.py index b8908853..ad4e3aec 100644 --- a/src/apify_client/clients/resource_clients/schedule.py +++ b/src/apify_client/_resource_clients/schedule.py @@ -2,8 +2,9 @@ from typing import Any -from apify_client._utils import catch_not_found_or_throw, filter_out_none_values_recursively, pluck_data_as_list -from apify_client.clients.base import ResourceClient, ResourceClientAsync +from apify_client._models import GetScheduleLogResponse, GetScheduleResponse, Schedule, ScheduleInvoked +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils import catch_not_found_or_throw, filter_none_values, response_to_dict from apify_client.errors import ApifyApiError @@ -37,7 +38,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'schedules') super().__init__(*args, resource_path=resource_path, **kwargs) - def get(self) -> dict | None: + def get(self) -> Schedule | None: """Return information about the schedule. https://docs.apify.com/api/v2#/reference/schedules/schedule-object/get-schedule @@ -45,7 +46,17 @@ def get(self) -> dict | None: Returns: The retrieved schedule. """ - return self._get() + try: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetScheduleResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None def update( self, @@ -58,7 +69,7 @@ def update( description: str | None = None, timezone: str | None = None, title: str | None = None, - ) -> dict: + ) -> Schedule: """Update the schedule with specified fields. https://docs.apify.com/api/v2#/reference/schedules/schedule-object/update-schedule @@ -88,17 +99,32 @@ def update( timezone=timezone, title=title, ) + cleaned = filter_none_values(schedule_representation) - return self._update(filter_out_none_values_recursively(schedule_representation)) + response = self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return GetScheduleResponse.model_validate(result).data def delete(self) -> None: """Delete the schedule. https://docs.apify.com/api/v2#/reference/schedules/schedule-object/delete-schedule """ - self._delete() + try: + self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) - def get_log(self) -> list | None: + def get_log(self) -> list[ScheduleInvoked] | None: """Return log for the given schedule. https://docs.apify.com/api/v2#/reference/schedules/schedule-log/get-schedule-log @@ -107,12 +133,15 @@ def get_log(self) -> list | None: Retrieved log of the given schedule. """ try: - response = self.http_client.call( - url=self._url('log'), + response = self._http_client.call( + url=self._build_url('log'), method='GET', - params=self._params(), + params=self._build_params(), ) - return pluck_data_as_list(response.json()) + response_as_dict = response_to_dict(response) + if response_as_dict is None: + return None + return GetScheduleLogResponse.model_validate(response_as_dict).data except ApifyApiError as exc: catch_not_found_or_throw(exc) @@ -126,7 +155,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'schedules') super().__init__(*args, resource_path=resource_path, **kwargs) - async def get(self) -> dict | None: + async def get(self) -> Schedule | None: """Return information about the schedule. https://docs.apify.com/api/v2#/reference/schedules/schedule-object/get-schedule @@ -134,7 +163,17 @@ async def get(self) -> dict | None: Returns: The retrieved schedule. """ - return await self._get() + try: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetScheduleResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None async def update( self, @@ -147,7 +186,7 @@ async def update( description: str | None = None, timezone: str | None = None, title: str | None = None, - ) -> dict: + ) -> Schedule: """Update the schedule with specified fields. https://docs.apify.com/api/v2#/reference/schedules/schedule-object/update-schedule @@ -177,17 +216,32 @@ async def update( timezone=timezone, title=title, ) + cleaned = filter_none_values(schedule_representation) - return await self._update(filter_out_none_values_recursively(schedule_representation)) + response = await self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return GetScheduleResponse.model_validate(result).data async def delete(self) -> None: """Delete the schedule. https://docs.apify.com/api/v2#/reference/schedules/schedule-object/delete-schedule """ - await self._delete() + try: + await self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) - async def get_log(self) -> list | None: + async def get_log(self) -> list[ScheduleInvoked] | None: """Return log for the given schedule. https://docs.apify.com/api/v2#/reference/schedules/schedule-log/get-schedule-log @@ -196,12 +250,15 @@ async def get_log(self) -> list | None: Retrieved log of the given schedule. """ try: - response = await self.http_client.call( - url=self._url('log'), + response = await self._http_client.call( + url=self._build_url('log'), method='GET', - params=self._params(), + params=self._build_params(), ) - return pluck_data_as_list(response.json()) + response_as_dict = response_to_dict(response) + if response_as_dict is None: + return None + return GetScheduleLogResponse.model_validate(response_as_dict).data except ApifyApiError as exc: catch_not_found_or_throw(exc) diff --git a/src/apify_client/clients/resource_clients/schedule_collection.py b/src/apify_client/_resource_clients/schedule_collection.py similarity index 73% rename from src/apify_client/clients/resource_clients/schedule_collection.py rename to src/apify_client/_resource_clients/schedule_collection.py index a4f23623..c849a2cf 100644 --- a/src/apify_client/clients/resource_clients/schedule_collection.py +++ b/src/apify_client/_resource_clients/schedule_collection.py @@ -1,16 +1,19 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any -from apify_client._utils import filter_out_none_values_recursively -from apify_client.clients.base import ResourceCollectionClient, ResourceCollectionClientAsync -from apify_client.clients.resource_clients.schedule import _get_schedule_representation +from apify_client._models import ( + GetListOfSchedulesResponse, + GetScheduleResponse, + ListOfSchedules, + Schedule, +) +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._resource_clients.schedule import _get_schedule_representation +from apify_client._utils import filter_none_values, response_to_dict -if TYPE_CHECKING: - from apify_client.clients.base.resource_collection_client import ListPage - -class ScheduleCollectionClient(ResourceCollectionClient): +class ScheduleCollectionClient(ResourceClient): """Sub-client for manipulating schedules.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -23,7 +26,7 @@ def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfSchedules: """List the available schedules. https://docs.apify.com/api/v2#/reference/schedules/schedules-collection/get-list-of-schedules @@ -36,7 +39,13 @@ def list( Returns: The list of available schedules matching the specified filters. """ - return self._list(limit=limit, offset=offset, desc=desc) + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfSchedulesResponse.model_validate(response_as_dict).data def create( self, @@ -49,7 +58,7 @@ def create( description: str | None = None, timezone: str | None = None, title: str | None = None, - ) -> dict: + ) -> Schedule: """Create a new schedule. https://docs.apify.com/api/v2#/reference/schedules/schedules-collection/create-schedule @@ -83,10 +92,18 @@ def create( title=title, ) - return self._create(filter_out_none_values_recursively(schedule_representation)) + response = self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(), + json=filter_none_values(schedule_representation), + ) + + result = response_to_dict(response) + return GetScheduleResponse.model_validate(result).data -class ScheduleCollectionClientAsync(ResourceCollectionClientAsync): +class ScheduleCollectionClientAsync(ResourceClientAsync): """Async sub-client for manipulating schedules.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -99,7 +116,7 @@ async def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfSchedules: """List the available schedules. https://docs.apify.com/api/v2#/reference/schedules/schedules-collection/get-list-of-schedules @@ -112,7 +129,13 @@ async def list( Returns: The list of available schedules matching the specified filters. """ - return await self._list(limit=limit, offset=offset, desc=desc) + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfSchedulesResponse.model_validate(response_as_dict).data async def create( self, @@ -125,7 +148,7 @@ async def create( description: str | None = None, timezone: str | None = None, title: str | None = None, - ) -> dict: + ) -> Schedule: """Create a new schedule. https://docs.apify.com/api/v2#/reference/schedules/schedules-collection/create-schedule @@ -159,4 +182,12 @@ async def create( title=title, ) - return await self._create(filter_out_none_values_recursively(schedule_representation)) + response = await self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(), + json=filter_none_values(schedule_representation), + ) + + result = response_to_dict(response) + return GetScheduleResponse.model_validate(result).data diff --git a/src/apify_client/clients/resource_clients/store_collection.py b/src/apify_client/_resource_clients/store_collection.py similarity index 60% rename from src/apify_client/clients/resource_clients/store_collection.py rename to src/apify_client/_resource_clients/store_collection.py index f04200a0..f95764d0 100644 --- a/src/apify_client/clients/resource_clients/store_collection.py +++ b/src/apify_client/_resource_clients/store_collection.py @@ -1,14 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any -from apify_client.clients.base import ResourceCollectionClient, ResourceCollectionClientAsync +from apify_client._models import GetListOfActorsInStoreResponse, ListOfStoreActors +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils import response_to_dict -if TYPE_CHECKING: - from apify_client.clients.base.resource_collection_client import ListPage - -class StoreCollectionClient(ResourceCollectionClient): +class StoreCollectionClient(ResourceClient): """Sub-client for Apify store.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -25,7 +24,7 @@ def list( category: str | None = None, username: str | None = None, pricing_model: str | None = None, - ) -> ListPage[dict]: + ) -> ListOfStoreActors: """List Actors in Apify store. https://docs.apify.com/api/v2/#/reference/store/store-actors-collection/get-list-of-actors-in-store @@ -41,20 +40,26 @@ def list( pricing_model: Filter by this pricing model. Returns: - The list of available tasks matching the specified filters. + The list of available actors matching the specified filters. """ - return self._list( - limit=limit, - offset=offset, - search=search, - sortBy=sort_by, - category=category, - username=username, - pricingModel=pricing_model, + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params( + limit=limit, + offset=offset, + search=search, + sortBy=sort_by, + category=category, + username=username, + pricingModel=pricing_model, + ), ) + response_as_dict = response_to_dict(response) + return GetListOfActorsInStoreResponse.model_validate(response_as_dict).data -class StoreCollectionClientAsync(ResourceCollectionClientAsync): +class StoreCollectionClientAsync(ResourceClientAsync): """Async sub-client for Apify store.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -71,7 +76,7 @@ async def list( category: str | None = None, username: str | None = None, pricing_model: str | None = None, - ) -> ListPage[dict]: + ) -> ListOfStoreActors: """List Actors in Apify store. https://docs.apify.com/api/v2/#/reference/store/store-actors-collection/get-list-of-actors-in-store @@ -87,14 +92,20 @@ async def list( pricing_model: Filter by this pricing model. Returns: - The list of available tasks matching the specified filters. + The list of available actors matching the specified filters. """ - return await self._list( - limit=limit, - offset=offset, - search=search, - sortBy=sort_by, - category=category, - username=username, - pricingModel=pricing_model, + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params( + limit=limit, + offset=offset, + search=search, + sortBy=sort_by, + category=category, + username=username, + pricingModel=pricing_model, + ), ) + response_as_dict = response_to_dict(response) + return GetListOfActorsInStoreResponse.model_validate(response_as_dict).data diff --git a/src/apify_client/clients/resource_clients/task.py b/src/apify_client/_resource_clients/task.py similarity index 76% rename from src/apify_client/clients/resource_clients/task.py rename to src/apify_client/_resource_clients/task.py index da0837d2..980db09f 100644 --- a/src/apify_client/clients/resource_clients/task.py +++ b/src/apify_client/_resource_clients/task.py @@ -2,25 +2,25 @@ from typing import TYPE_CHECKING, Any, cast +from apify_client._models import CreateTaskResponse, GetRunResponse, Run, RunOrigin, Task +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._resource_clients.run import RunClient, RunClientAsync from apify_client._utils import ( catch_not_found_or_throw, encode_webhook_list_to_base64, - filter_out_none_values_recursively, - maybe_extract_enum_member_value, - parse_date_fields, - pluck_data, -) -from apify_client.clients.base import ResourceClient, ResourceClientAsync -from apify_client.clients.resource_clients.run import RunClient, RunClientAsync -from apify_client.clients.resource_clients.run_collection import RunCollectionClient, RunCollectionClientAsync -from apify_client.clients.resource_clients.webhook_collection import ( - WebhookCollectionClient, - WebhookCollectionClientAsync, + enum_to_value, + filter_none_values, + response_to_dict, ) from apify_client.errors import ApifyApiError if TYPE_CHECKING: - from apify_shared.consts import ActorJobStatus, MetaOrigin + from apify_client._consts import ActorJobStatus + from apify_client._resource_clients.run_collection import RunCollectionClient, RunCollectionClientAsync + from apify_client._resource_clients.webhook_collection import ( + WebhookCollectionClient, + WebhookCollectionClientAsync, + ) def get_task_representation( @@ -41,7 +41,7 @@ def get_task_representation( restart_on_error: bool | None = None, ) -> dict: """Get the dictionary representation of a task.""" - return { + task_dict = { 'actId': actor_id, 'name': name, 'options': { @@ -53,24 +53,42 @@ def get_task_representation( }, 'input': task_input, 'title': title, - 'actorStandby': { + } + + # Only include actorStandby if at least one field is provided + if any( + [ + actor_standby_desired_requests_per_actor_run is not None, + actor_standby_max_requests_per_actor_run is not None, + actor_standby_idle_timeout_secs is not None, + actor_standby_build is not None, + actor_standby_memory_mbytes is not None, + ] + ): + task_dict['actorStandby'] = { 'desiredRequestsPerActorRun': actor_standby_desired_requests_per_actor_run, 'maxRequestsPerActorRun': actor_standby_max_requests_per_actor_run, 'idleTimeoutSecs': actor_standby_idle_timeout_secs, 'build': actor_standby_build, 'memoryMbytes': actor_standby_memory_mbytes, - }, - } + } + + return task_dict class TaskClient(ResourceClient): """Sub-client for manipulating a single task.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: - resource_path = kwargs.pop('resource_path', 'actor-tasks') - super().__init__(*args, resource_path=resource_path, **kwargs) + def __init__( + self, + *, + resource_id: str, + resource_path: str = 'actor-tasks', + **kwargs: Any, + ) -> None: + super().__init__(resource_id=resource_id, resource_path=resource_path, **kwargs) - def get(self) -> dict | None: + def get(self) -> Task | None: """Retrieve the task. https://docs.apify.com/api/v2#/reference/actor-tasks/task-object/get-task @@ -78,7 +96,17 @@ def get(self) -> dict | None: Returns: The retrieved task. """ - return self._get() + try: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return CreateTaskResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None def update( self, @@ -96,7 +124,7 @@ def update( actor_standby_idle_timeout_secs: int | None = None, actor_standby_build: str | None = None, actor_standby_memory_mbytes: int | None = None, - ) -> dict: + ) -> Task: """Update the task with specified fields. https://docs.apify.com/api/v2#/reference/actor-tasks/task-object/update-task @@ -142,15 +170,30 @@ def update( actor_standby_build=actor_standby_build, actor_standby_memory_mbytes=actor_standby_memory_mbytes, ) + cleaned = filter_none_values(task_representation) - return self._update(filter_out_none_values_recursively(task_representation)) + response = self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return CreateTaskResponse.model_validate(result).data def delete(self) -> None: """Delete the task. https://docs.apify.com/api/v2#/reference/actor-tasks/task-object/delete-task """ - return self._delete() + try: + self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) def start( self, @@ -163,7 +206,7 @@ def start( restart_on_error: bool | None = None, wait_for_finish: int | None = None, webhooks: list[dict] | None = None, - ) -> dict: + ) -> Run: """Start the task and immediately return the Run object. https://docs.apify.com/api/v2#/reference/actor-tasks/run-collection/run-task @@ -193,7 +236,7 @@ def start( Returns: The run object. """ - request_params = self._params( + request_params = self._build_params( build=build, maxItems=max_items, memory=memory_mbytes, @@ -203,15 +246,16 @@ def start( webhooks=encode_webhook_list_to_base64(webhooks) if webhooks is not None else None, ) - response = self.http_client.call( - url=self._url('runs'), + response = self._http_client.call( + url=self._build_url('runs'), method='POST', headers={'content-type': 'application/json; charset=utf-8'}, json=task_input, params=request_params, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return GetRunResponse.model_validate(result).data def call( self, @@ -224,7 +268,7 @@ def call( restart_on_error: bool | None = None, webhooks: list[dict] | None = None, wait_secs: int | None = None, - ) -> dict | None: + ) -> Run | None: """Start a task and wait for it to finish before returning the Run object. It waits indefinitely, unless the wait_secs argument is provided. @@ -262,7 +306,14 @@ def call( webhooks=webhooks, ) - return self.root_client.run(started_run['id']).wait_for_finish(wait_secs=wait_secs) + run_client = self._client_registry.run_client( + resource_id=started_run.id, + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + ) + return run_client.wait_for_finish(wait_secs=wait_secs) def get_input(self) -> dict | None: """Retrieve the default input for this task. @@ -273,10 +324,10 @@ def get_input(self) -> dict | None: Retrieved task input. """ try: - response = self.http_client.call( - url=self._url('input'), + response = self._http_client.call( + url=self._build_url('input'), method='GET', - params=self._params(), + params=self._build_params(), ) return cast('dict', response.json()) except ApifyApiError as exc: @@ -291,19 +342,22 @@ def update_input(self, *, task_input: dict) -> dict: Returns: Retrieved task input. """ - response = self.http_client.call( - url=self._url('input'), + response = self._http_client.call( + url=self._build_url('input'), method='PUT', - params=self._params(), + params=self._build_params(), json=task_input, ) return cast('dict', response.json()) def runs(self) -> RunCollectionClient: """Retrieve a client for the runs of this task.""" - return RunCollectionClient(**self._sub_resource_init_options(resource_path='runs')) + return self._client_registry.run_collection_client( + resource_path='runs', + **self._base_client_kwargs, + ) - def last_run(self, *, status: ActorJobStatus | None = None, origin: MetaOrigin | None = None) -> RunClient: + def last_run(self, *, status: ActorJobStatus | None = None, origin: RunOrigin | None = None) -> RunClient: """Retrieve the client for the last run of this task. Last run is retrieved based on the start time of the runs. @@ -316,29 +370,36 @@ def last_run(self, *, status: ActorJobStatus | None = None, origin: MetaOrigin | The resource client for the last run of this task. """ return RunClient( - **self._sub_resource_init_options( - resource_id='last', - resource_path='runs', - params=self._params( - status=maybe_extract_enum_member_value(status), - origin=maybe_extract_enum_member_value(origin), - ), - ) + resource_id='last', + resource_path='runs', + base_url=self._resource_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + params=self._build_params( + status=enum_to_value(status), + origin=enum_to_value(origin), + ), ) def webhooks(self) -> WebhookCollectionClient: """Retrieve a client for webhooks associated with this task.""" - return WebhookCollectionClient(**self._sub_resource_init_options()) + return self._client_registry.webhook_collection_client(**self._base_client_kwargs) class TaskClientAsync(ResourceClientAsync): """Async sub-client for manipulating a single task.""" - def __init__(self, *args: Any, **kwargs: Any) -> None: - resource_path = kwargs.pop('resource_path', 'actor-tasks') - super().__init__(*args, resource_path=resource_path, **kwargs) + def __init__( + self, + *, + resource_id: str, + resource_path: str = 'actor-tasks', + **kwargs: Any, + ) -> None: + super().__init__(resource_id=resource_id, resource_path=resource_path, **kwargs) - async def get(self) -> dict | None: + async def get(self) -> Task | None: """Retrieve the task. https://docs.apify.com/api/v2#/reference/actor-tasks/task-object/get-task @@ -346,7 +407,17 @@ async def get(self) -> dict | None: Returns: The retrieved task. """ - return await self._get() + try: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return CreateTaskResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None async def update( self, @@ -364,7 +435,7 @@ async def update( actor_standby_idle_timeout_secs: int | None = None, actor_standby_build: str | None = None, actor_standby_memory_mbytes: int | None = None, - ) -> dict: + ) -> Task: """Update the task with specified fields. https://docs.apify.com/api/v2#/reference/actor-tasks/task-object/update-task @@ -410,15 +481,30 @@ async def update( actor_standby_build=actor_standby_build, actor_standby_memory_mbytes=actor_standby_memory_mbytes, ) + cleaned = filter_none_values(task_representation) - return await self._update(filter_out_none_values_recursively(task_representation)) + response = await self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return CreateTaskResponse.model_validate(result).data async def delete(self) -> None: """Delete the task. https://docs.apify.com/api/v2#/reference/actor-tasks/task-object/delete-task """ - return await self._delete() + try: + await self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) async def start( self, @@ -431,7 +517,7 @@ async def start( restart_on_error: bool | None = None, wait_for_finish: int | None = None, webhooks: list[dict] | None = None, - ) -> dict: + ) -> Run: """Start the task and immediately return the Run object. https://docs.apify.com/api/v2#/reference/actor-tasks/run-collection/run-task @@ -461,7 +547,7 @@ async def start( Returns: The run object. """ - request_params = self._params( + request_params = self._build_params( build=build, maxItems=max_items, memory=memory_mbytes, @@ -471,15 +557,16 @@ async def start( webhooks=encode_webhook_list_to_base64(webhooks) if webhooks is not None else None, ) - response = await self.http_client.call( - url=self._url('runs'), + response = await self._http_client.call( + url=self._build_url('runs'), method='POST', headers={'content-type': 'application/json; charset=utf-8'}, json=task_input, params=request_params, ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return GetRunResponse.model_validate(result).data async def call( self, @@ -492,7 +579,7 @@ async def call( restart_on_error: bool | None = None, webhooks: list[dict] | None = None, wait_secs: int | None = None, - ) -> dict | None: + ) -> Run | None: """Start a task and wait for it to finish before returning the Run object. It waits indefinitely, unless the wait_secs argument is provided. @@ -529,8 +616,14 @@ async def call( restart_on_error=restart_on_error, webhooks=webhooks, ) - - return await self.root_client.run(started_run['id']).wait_for_finish(wait_secs=wait_secs) + run_client = self._client_registry.run_client( + resource_id=started_run.id, + base_url=self._base_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + ) + return await run_client.wait_for_finish(wait_secs=wait_secs) async def get_input(self) -> dict | None: """Retrieve the default input for this task. @@ -541,10 +634,10 @@ async def get_input(self) -> dict | None: Retrieved task input. """ try: - response = await self.http_client.call( - url=self._url('input'), + response = await self._http_client.call( + url=self._build_url('input'), method='GET', - params=self._params(), + params=self._build_params(), ) return cast('dict', response.json()) except ApifyApiError as exc: @@ -559,19 +652,22 @@ async def update_input(self, *, task_input: dict) -> dict: Returns: Retrieved task input. """ - response = await self.http_client.call( - url=self._url('input'), + response = await self._http_client.call( + url=self._build_url('input'), method='PUT', - params=self._params(), + params=self._build_params(), json=task_input, ) return cast('dict', response.json()) def runs(self) -> RunCollectionClientAsync: """Retrieve a client for the runs of this task.""" - return RunCollectionClientAsync(**self._sub_resource_init_options(resource_path='runs')) + return self._client_registry.run_collection_client( + resource_path='runs', + **self._base_client_kwargs, + ) - def last_run(self, *, status: ActorJobStatus | None = None, origin: MetaOrigin | None = None) -> RunClientAsync: + def last_run(self, *, status: ActorJobStatus | None = None, origin: RunOrigin | None = None) -> RunClientAsync: """Retrieve the client for the last run of this task. Last run is retrieved based on the start time of the runs. @@ -584,16 +680,18 @@ def last_run(self, *, status: ActorJobStatus | None = None, origin: MetaOrigin | The resource client for the last run of this task. """ return RunClientAsync( - **self._sub_resource_init_options( - resource_id='last', - resource_path='runs', - params=self._params( - status=maybe_extract_enum_member_value(status), - origin=maybe_extract_enum_member_value(origin), - ), - ) + resource_id='last', + resource_path='runs', + base_url=self._resource_url, + public_base_url=self._public_base_url, + http_client=self._http_client, + client_registry=self._client_registry, + params=self._build_params( + status=enum_to_value(status), + origin=enum_to_value(origin), + ), ) def webhooks(self) -> WebhookCollectionClientAsync: """Retrieve a client for webhooks associated with this task.""" - return WebhookCollectionClientAsync(**self._sub_resource_init_options()) + return self._client_registry.webhook_collection_client(**self._base_client_kwargs) diff --git a/src/apify_client/clients/resource_clients/task_collection.py b/src/apify_client/_resource_clients/task_collection.py similarity index 82% rename from src/apify_client/clients/resource_clients/task_collection.py rename to src/apify_client/_resource_clients/task_collection.py index 0f8fe188..34725128 100644 --- a/src/apify_client/clients/resource_clients/task_collection.py +++ b/src/apify_client/_resource_clients/task_collection.py @@ -1,16 +1,14 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any -from apify_client._utils import filter_out_none_values_recursively -from apify_client.clients.base import ResourceCollectionClient, ResourceCollectionClientAsync -from apify_client.clients.resource_clients.task import get_task_representation +from apify_client._models import CreateTaskResponse, GetListOfTasksResponse, ListOfTasks, Task +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._resource_clients.task import get_task_representation +from apify_client._utils import filter_none_values, response_to_dict -if TYPE_CHECKING: - from apify_client.clients.base.resource_collection_client import ListPage - -class TaskCollectionClient(ResourceCollectionClient): +class TaskCollectionClient(ResourceClient): """Sub-client for manipulating tasks.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -23,7 +21,7 @@ def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfTasks: """List the available tasks. https://docs.apify.com/api/v2#/reference/actor-tasks/task-collection/get-list-of-tasks @@ -36,7 +34,13 @@ def list( Returns: The list of available tasks matching the specified filters. """ - return self._list(limit=limit, offset=offset, desc=desc) + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfTasksResponse.model_validate(response_as_dict).data def create( self, @@ -55,7 +59,7 @@ def create( actor_standby_idle_timeout_secs: int | None = None, actor_standby_build: str | None = None, actor_standby_memory_mbytes: int | None = None, - ) -> dict: + ) -> Task: """Create a new task. https://docs.apify.com/api/v2#/reference/actor-tasks/task-collection/create-task @@ -104,10 +108,18 @@ def create( actor_standby_memory_mbytes=actor_standby_memory_mbytes, ) - return self._create(filter_out_none_values_recursively(task_representation)) + response = self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(), + json=filter_none_values(task_representation), + ) + + result = response_to_dict(response) + return CreateTaskResponse.model_validate(result).data -class TaskCollectionClientAsync(ResourceCollectionClientAsync): +class TaskCollectionClientAsync(ResourceClientAsync): """Async sub-client for manipulating tasks.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -120,7 +132,7 @@ async def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfTasks: """List the available tasks. https://docs.apify.com/api/v2#/reference/actor-tasks/task-collection/get-list-of-tasks @@ -133,7 +145,13 @@ async def list( Returns: The list of available tasks matching the specified filters. """ - return await self._list(limit=limit, offset=offset, desc=desc) + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfTasksResponse.model_validate(response_as_dict).data async def create( self, @@ -152,7 +170,7 @@ async def create( actor_standby_idle_timeout_secs: int | None = None, actor_standby_build: str | None = None, actor_standby_memory_mbytes: int | None = None, - ) -> dict: + ) -> Task: """Create a new task. https://docs.apify.com/api/v2#/reference/actor-tasks/task-collection/create-task @@ -201,4 +219,12 @@ async def create( actor_standby_memory_mbytes=actor_standby_memory_mbytes, ) - return await self._create(filter_out_none_values_recursively(task_representation)) + response = await self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(), + json=filter_none_values(task_representation), + ) + + result = response_to_dict(response) + return CreateTaskResponse.model_validate(result).data diff --git a/src/apify_client/clients/resource_clients/user.py b/src/apify_client/_resource_clients/user.py similarity index 56% rename from src/apify_client/clients/resource_clients/user.py rename to src/apify_client/_resource_clients/user.py index 86a81c07..498f9c22 100644 --- a/src/apify_client/clients/resource_clients/user.py +++ b/src/apify_client/_resource_clients/user.py @@ -2,13 +2,16 @@ from typing import Any -from apify_client._utils import ( - catch_not_found_or_throw, - filter_out_none_values_recursively, - parse_date_fields, - pluck_data, +from apify_client._models import ( + AccountLimits, + GetPrivateUserDataResponse, + GetPublicUserDataResponse, + MonthlyUsage, + UserPrivateInfo, + UserPublicInfo, ) -from apify_client.clients.base import ResourceClient, ResourceClientAsync +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils import catch_not_found_or_throw, filter_none_values, response_to_dict from apify_client.errors import ApifyApiError @@ -22,7 +25,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'users') super().__init__(*args, resource_id=resource_id, resource_path=resource_path, **kwargs) - def get(self) -> dict | None: + def get(self) -> UserPublicInfo | UserPrivateInfo | None: """Return information about user account. You receive all or only public info based on your token permissions. @@ -32,9 +35,23 @@ def get(self) -> dict | None: Returns: The retrieved user data, or None if the user does not exist. """ - return self._get() + try: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + # Try to parse as UserPrivateInfo first (has more fields), fall back to UserPublicInfo + try: + return GetPrivateUserDataResponse.model_validate(result).data + except Exception: + return GetPublicUserDataResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None - def monthly_usage(self) -> dict | None: + def monthly_usage(self) -> MonthlyUsage | None: """Return monthly usage of the user account. This includes a complete usage summary for the current usage cycle, an overall sum, as well as a daily breakdown @@ -47,19 +64,23 @@ def monthly_usage(self) -> dict | None: The retrieved request, or None, if it did not exist. """ try: - response = self.http_client.call( - url=self._url('usage/monthly'), + response = self._http_client.call( + url=self._build_url('usage/monthly'), method='GET', - params=self._params(), + params=self._build_params(), ) - return parse_date_fields(pluck_data(response.json())) + response_as_dict = response_to_dict(response) + if response_as_dict is None: + return None + # API returns {data: {...}} structure + return MonthlyUsage.model_validate(response_as_dict.get('data', {})) except ApifyApiError as exc: catch_not_found_or_throw(exc) return None - def limits(self) -> dict | None: + def limits(self) -> AccountLimits | None: """Return a complete summary of the user account's limits. It is the same information which is available on the account's Limits page. The returned data includes @@ -71,12 +92,16 @@ def limits(self) -> dict | None: The retrieved request, or None, if it did not exist. """ try: - response = self.http_client.call( - url=self._url('limits'), + response = self._http_client.call( + url=self._build_url('limits'), method='GET', - params=self._params(), + params=self._build_params(), ) - return parse_date_fields(pluck_data(response.json())) + response_as_dict = response_to_dict(response) + if response_as_dict is None: + return None + # API returns {data: {...}} structure + return AccountLimits.model_validate(response_as_dict.get('data', {})) except ApifyApiError as exc: catch_not_found_or_throw(exc) @@ -90,11 +115,11 @@ def update_limits( data_retention_days: int | None = None, ) -> None: """Update the account's limits manageable on your account's Limits page.""" - self.http_client.call( - url=self._url('limits'), + self._http_client.call( + url=self._build_url('limits'), method='PUT', - params=self._params(), - json=filter_out_none_values_recursively( + params=self._build_params(), + json=filter_none_values( { 'maxMonthlyUsageUsd': max_monthly_usage_usd, 'dataRetentionDays': data_retention_days, @@ -113,7 +138,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'users') super().__init__(*args, resource_id=resource_id, resource_path=resource_path, **kwargs) - async def get(self) -> dict | None: + async def get(self) -> UserPublicInfo | UserPrivateInfo | None: """Return information about user account. You receive all or only public info based on your token permissions. @@ -123,9 +148,23 @@ async def get(self) -> dict | None: Returns: The retrieved user data, or None if the user does not exist. """ - return await self._get() + try: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + # Try to parse as UserPrivateInfo first (has more fields), fall back to UserPublicInfo + try: + return GetPrivateUserDataResponse.model_validate(result).data + except Exception: + return GetPublicUserDataResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None - async def monthly_usage(self) -> dict | None: + async def monthly_usage(self) -> MonthlyUsage | None: """Return monthly usage of the user account. This includes a complete usage summary for the current usage cycle, an overall sum, as well as a daily breakdown @@ -138,19 +177,23 @@ async def monthly_usage(self) -> dict | None: The retrieved request, or None, if it did not exist. """ try: - response = await self.http_client.call( - url=self._url('usage/monthly'), + response = await self._http_client.call( + url=self._build_url('usage/monthly'), method='GET', - params=self._params(), + params=self._build_params(), ) - return parse_date_fields(pluck_data(response.json())) + response_as_dict = response_to_dict(response) + if response_as_dict is None: + return None + # API returns {data: {...}} structure + return MonthlyUsage.model_validate(response_as_dict.get('data', {})) except ApifyApiError as exc: catch_not_found_or_throw(exc) return None - async def limits(self) -> dict | None: + async def limits(self) -> AccountLimits | None: """Return a complete summary of the user account's limits. It is the same information which is available on the account's Limits page. The returned data includes @@ -162,12 +205,16 @@ async def limits(self) -> dict | None: The retrieved request, or None, if it did not exist. """ try: - response = await self.http_client.call( - url=self._url('limits'), + response = await self._http_client.call( + url=self._build_url('limits'), method='GET', - params=self._params(), + params=self._build_params(), ) - return parse_date_fields(pluck_data(response.json())) + response_as_dict = response_to_dict(response) + if response_as_dict is None: + return None + # API returns {data: {...}} structure + return AccountLimits.model_validate(response_as_dict.get('data', {})) except ApifyApiError as exc: catch_not_found_or_throw(exc) @@ -181,11 +228,11 @@ async def update_limits( data_retention_days: int | None = None, ) -> None: """Update the account's limits manageable on your account's Limits page.""" - await self.http_client.call( - url=self._url('limits'), + await self._http_client.call( + url=self._build_url('limits'), method='PUT', - params=self._params(), - json=filter_out_none_values_recursively( + params=self._build_params(), + json=filter_none_values( { 'maxMonthlyUsageUsd': max_monthly_usage_usd, 'dataRetentionDays': data_retention_days, diff --git a/src/apify_client/clients/resource_clients/webhook.py b/src/apify_client/_resource_clients/webhook.py similarity index 69% rename from src/apify_client/clients/resource_clients/webhook.py rename to src/apify_client/_resource_clients/webhook.py index 559485a6..746b19f7 100644 --- a/src/apify_client/clients/resource_clients/webhook.py +++ b/src/apify_client/_resource_clients/webhook.py @@ -2,22 +2,28 @@ from typing import TYPE_CHECKING, Any +from apify_client._models import ( + GetWebhookResponse, + TestWebhookResponse, + UpdateWebhookResponse, + Webhook, + WebhookDispatch, +) +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync from apify_client._utils import ( catch_not_found_or_throw, - filter_out_none_values_recursively, - maybe_extract_enum_member_value, - parse_date_fields, - pluck_data, -) -from apify_client.clients.base import ResourceClient, ResourceClientAsync -from apify_client.clients.resource_clients.webhook_dispatch_collection import ( - WebhookDispatchCollectionClient, - WebhookDispatchCollectionClientAsync, + enum_to_value, + filter_none_values, + response_to_dict, ) from apify_client.errors import ApifyApiError if TYPE_CHECKING: - from apify_shared.consts import WebhookEventType + from apify_client._consts import WebhookEventType + from apify_client._resource_clients.webhook_dispatch_collection import ( + WebhookDispatchCollectionClient, + WebhookDispatchCollectionClientAsync, + ) def get_webhook_representation( @@ -54,7 +60,7 @@ def get_webhook_representation( webhook['isAdHoc'] = True if event_types is not None: - webhook['eventTypes'] = [maybe_extract_enum_member_value(event_type) for event_type in event_types] + webhook['eventTypes'] = [enum_to_value(event_type) for event_type in event_types] return webhook @@ -66,7 +72,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'webhooks') super().__init__(*args, resource_path=resource_path, **kwargs) - def get(self) -> dict | None: + def get(self) -> Webhook | None: """Retrieve the webhook. https://docs.apify.com/api/v2#/reference/webhooks/webhook-object/get-webhook @@ -74,7 +80,17 @@ def get(self) -> dict | None: Returns: The retrieved webhook, or None if it does not exist. """ - return self._get() + try: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetWebhookResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None def update( self, @@ -89,7 +105,7 @@ def update( ignore_ssl_errors: bool | None = None, do_not_retry: bool | None = None, is_ad_hoc: bool | None = None, - ) -> dict: + ) -> Webhook: """Update the webhook. https://docs.apify.com/api/v2#/reference/webhooks/webhook-object/update-webhook @@ -122,17 +138,32 @@ def update( do_not_retry=do_not_retry, is_ad_hoc=is_ad_hoc, ) + cleaned = filter_none_values(webhook_representation) - return self._update(filter_out_none_values_recursively(webhook_representation)) + response = self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return UpdateWebhookResponse.model_validate(result).data def delete(self) -> None: """Delete the webhook. https://docs.apify.com/api/v2#/reference/webhooks/webhook-object/delete-webhook """ - return self._delete() + try: + self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) - def test(self) -> dict | None: + def test(self) -> WebhookDispatch | None: """Test a webhook. Creates a webhook dispatch with a dummy payload. @@ -143,13 +174,14 @@ def test(self) -> dict | None: The webhook dispatch created by the test. """ try: - response = self.http_client.call( - url=self._url('test'), + response = self._http_client.call( + url=self._build_url('test'), method='POST', - params=self._params(), + params=self._build_params(), ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return TestWebhookResponse.model_validate(result).data if result is not None else None except ApifyApiError as exc: catch_not_found_or_throw(exc) @@ -164,8 +196,9 @@ def dispatches(self) -> WebhookDispatchCollectionClient: Returns: A client allowing access to dispatches of this webhook using its list method. """ - return WebhookDispatchCollectionClient( - **self._sub_resource_init_options(resource_path='dispatches'), + return self._client_registry.webhook_dispatch_collection_client( + resource_path='dispatches', + **self._base_client_kwargs, ) @@ -176,7 +209,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'webhooks') super().__init__(*args, resource_path=resource_path, **kwargs) - async def get(self) -> dict | None: + async def get(self) -> Webhook | None: """Retrieve the webhook. https://docs.apify.com/api/v2#/reference/webhooks/webhook-object/get-webhook @@ -184,7 +217,17 @@ async def get(self) -> dict | None: Returns: The retrieved webhook, or None if it does not exist. """ - return await self._get() + try: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetWebhookResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None async def update( self, @@ -199,7 +242,7 @@ async def update( ignore_ssl_errors: bool | None = None, do_not_retry: bool | None = None, is_ad_hoc: bool | None = None, - ) -> dict: + ) -> Webhook: """Update the webhook. https://docs.apify.com/api/v2#/reference/webhooks/webhook-object/update-webhook @@ -232,17 +275,32 @@ async def update( do_not_retry=do_not_retry, is_ad_hoc=is_ad_hoc, ) + cleaned = filter_none_values(webhook_representation) - return await self._update(filter_out_none_values_recursively(webhook_representation)) + response = await self._http_client.call( + url=self._build_url(), + method='PUT', + params=self._build_params(), + json=cleaned, + ) + result = response_to_dict(response) + return UpdateWebhookResponse.model_validate(result).data async def delete(self) -> None: """Delete the webhook. https://docs.apify.com/api/v2#/reference/webhooks/webhook-object/delete-webhook """ - return await self._delete() + try: + await self._http_client.call( + url=self._build_url(), + method='DELETE', + params=self._build_params(), + ) + except ApifyApiError as exc: + catch_not_found_or_throw(exc) - async def test(self) -> dict | None: + async def test(self) -> WebhookDispatch | None: """Test a webhook. Creates a webhook dispatch with a dummy payload. @@ -253,13 +311,14 @@ async def test(self) -> dict | None: The webhook dispatch created by the test. """ try: - response = await self.http_client.call( - url=self._url('test'), + response = await self._http_client.call( + url=self._build_url('test'), method='POST', - params=self._params(), + params=self._build_params(), ) - return parse_date_fields(pluck_data(response.json())) + result = response.json() + return TestWebhookResponse.model_validate(result).data if result is not None else None except ApifyApiError as exc: catch_not_found_or_throw(exc) @@ -274,6 +333,7 @@ def dispatches(self) -> WebhookDispatchCollectionClientAsync: Returns: A client allowing access to dispatches of this webhook using its list method. """ - return WebhookDispatchCollectionClientAsync( - **self._sub_resource_init_options(resource_path='dispatches'), + return self._client_registry.webhook_dispatch_collection_client( + resource_path='dispatches', + **self._base_client_kwargs, ) diff --git a/src/apify_client/clients/resource_clients/webhook_collection.py b/src/apify_client/_resource_clients/webhook_collection.py similarity index 78% rename from src/apify_client/clients/resource_clients/webhook_collection.py rename to src/apify_client/_resource_clients/webhook_collection.py index 7219eade..ce119241 100644 --- a/src/apify_client/clients/resource_clients/webhook_collection.py +++ b/src/apify_client/_resource_clients/webhook_collection.py @@ -2,17 +2,16 @@ from typing import TYPE_CHECKING, Any -from apify_client._utils import filter_out_none_values_recursively -from apify_client.clients.base import ResourceCollectionClient, ResourceCollectionClientAsync -from apify_client.clients.resource_clients.webhook import get_webhook_representation +from apify_client._models import CreateWebhookResponse, GetListOfWebhooksResponse, ListOfWebhooks, Webhook +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._resource_clients.webhook import get_webhook_representation +from apify_client._utils import filter_none_values, response_to_dict if TYPE_CHECKING: - from apify_shared.consts import WebhookEventType + from apify_client._consts import WebhookEventType - from apify_client.clients.base.resource_collection_client import ListPage - -class WebhookCollectionClient(ResourceCollectionClient): +class WebhookCollectionClient(ResourceClient): """Sub-client for manipulating webhooks.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -25,7 +24,7 @@ def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfWebhooks: """List the available webhooks. https://docs.apify.com/api/v2#/reference/webhooks/webhook-collection/get-list-of-webhooks @@ -38,7 +37,13 @@ def list( Returns: The list of available webhooks matching the specified filters. """ - return self._list(limit=limit, offset=offset, desc=desc) + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfWebhooksResponse.model_validate(response_as_dict).data def create( self, @@ -54,7 +59,7 @@ def create( do_not_retry: bool | None = None, idempotency_key: str | None = None, is_ad_hoc: bool | None = None, - ) -> dict: + ) -> Webhook: """Create a new webhook. You have to specify exactly one out of actor_id, actor_task_id or actor_run_id. @@ -93,10 +98,18 @@ def create( is_ad_hoc=is_ad_hoc, ) - return self._create(filter_out_none_values_recursively(webhook_representation)) + response = self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(), + json=filter_none_values(webhook_representation), + ) + + result = response_to_dict(response) + return CreateWebhookResponse.model_validate(result).data -class WebhookCollectionClientAsync(ResourceCollectionClientAsync): +class WebhookCollectionClientAsync(ResourceClientAsync): """Async sub-client for manipulating webhooks.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -109,7 +122,7 @@ async def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfWebhooks: """List the available webhooks. https://docs.apify.com/api/v2#/reference/webhooks/webhook-collection/get-list-of-webhooks @@ -122,7 +135,13 @@ async def list( Returns: The list of available webhooks matching the specified filters. """ - return await self._list(limit=limit, offset=offset, desc=desc) + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return GetListOfWebhooksResponse.model_validate(response_as_dict).data async def create( self, @@ -138,7 +157,7 @@ async def create( do_not_retry: bool | None = None, idempotency_key: str | None = None, is_ad_hoc: bool | None = None, - ) -> dict: + ) -> Webhook: """Create a new webhook. You have to specify exactly one out of actor_id, actor_task_id or actor_run_id. @@ -177,4 +196,12 @@ async def create( is_ad_hoc=is_ad_hoc, ) - return await self._create(filter_out_none_values_recursively(webhook_representation)) + response_obj = await self._http_client.call( + url=self._build_url(), + method='POST', + params=self._build_params(), + json=filter_none_values(webhook_representation), + ) + + response = response_to_dict(response_obj) + return CreateWebhookResponse.model_validate(response).data diff --git a/src/apify_client/clients/resource_clients/webhook_dispatch.py b/src/apify_client/_resource_clients/webhook_dispatch.py similarity index 50% rename from src/apify_client/clients/resource_clients/webhook_dispatch.py rename to src/apify_client/_resource_clients/webhook_dispatch.py index 30a2a26e..8ddd392f 100644 --- a/src/apify_client/clients/resource_clients/webhook_dispatch.py +++ b/src/apify_client/_resource_clients/webhook_dispatch.py @@ -2,7 +2,10 @@ from typing import Any -from apify_client.clients.base import ResourceClient, ResourceClientAsync +from apify_client._models import GetWebhookDispatchResponse, WebhookDispatch +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils import catch_not_found_or_throw, response_to_dict +from apify_client.errors import ApifyApiError class WebhookDispatchClient(ResourceClient): @@ -12,7 +15,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'webhook-dispatches') super().__init__(*args, resource_path=resource_path, **kwargs) - def get(self) -> dict | None: + def get(self) -> WebhookDispatch | None: """Retrieve the webhook dispatch. https://docs.apify.com/api/v2#/reference/webhook-dispatches/webhook-dispatch-object/get-webhook-dispatch @@ -20,7 +23,17 @@ def get(self) -> dict | None: Returns: The retrieved webhook dispatch, or None if it does not exist. """ - return self._get() + try: + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetWebhookDispatchResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None class WebhookDispatchClientAsync(ResourceClientAsync): @@ -30,7 +43,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: resource_path = kwargs.pop('resource_path', 'webhook-dispatches') super().__init__(*args, resource_path=resource_path, **kwargs) - async def get(self) -> dict | None: + async def get(self) -> WebhookDispatch | None: """Retrieve the webhook dispatch. https://docs.apify.com/api/v2#/reference/webhook-dispatches/webhook-dispatch-object/get-webhook-dispatch @@ -38,4 +51,14 @@ async def get(self) -> dict | None: Returns: The retrieved webhook dispatch, or None if it does not exist. """ - return await self._get() + try: + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(), + ) + result = response_to_dict(response) + return GetWebhookDispatchResponse.model_validate(result).data + except ApifyApiError as exc: + catch_not_found_or_throw(exc) + return None diff --git a/src/apify_client/clients/resource_clients/webhook_dispatch_collection.py b/src/apify_client/_resource_clients/webhook_dispatch_collection.py similarity index 64% rename from src/apify_client/clients/resource_clients/webhook_dispatch_collection.py rename to src/apify_client/_resource_clients/webhook_dispatch_collection.py index 60ac1df1..087d95b9 100644 --- a/src/apify_client/clients/resource_clients/webhook_dispatch_collection.py +++ b/src/apify_client/_resource_clients/webhook_dispatch_collection.py @@ -1,14 +1,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import Any -from apify_client.clients.base import ResourceCollectionClient, ResourceCollectionClientAsync +from apify_client._models import ListOfWebhookDispatches, WebhookDispatchList +from apify_client._resource_clients._resource_client import ResourceClient, ResourceClientAsync +from apify_client._utils import response_to_dict -if TYPE_CHECKING: - from apify_client.clients.base.resource_collection_client import ListPage - -class WebhookDispatchCollectionClient(ResourceCollectionClient): +class WebhookDispatchCollectionClient(ResourceClient): """Sub-client for listing webhook dispatches.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -21,7 +20,7 @@ def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfWebhookDispatches | None: """List all webhook dispatches of a user. https://docs.apify.com/api/v2#/reference/webhook-dispatches/webhook-dispatches-collection/get-list-of-webhook-dispatches @@ -34,10 +33,16 @@ def list( Returns: The retrieved webhook dispatches of a user. """ - return self._list(limit=limit, offset=offset, desc=desc) + response = self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return WebhookDispatchList.model_validate(response_as_dict).data -class WebhookDispatchCollectionClientAsync(ResourceCollectionClientAsync): +class WebhookDispatchCollectionClientAsync(ResourceClientAsync): """Async sub-client for listing webhook dispatches.""" def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -50,7 +55,7 @@ async def list( limit: int | None = None, offset: int | None = None, desc: bool | None = None, - ) -> ListPage[dict]: + ) -> ListOfWebhookDispatches | None: """List all webhook dispatches of a user. https://docs.apify.com/api/v2#/reference/webhook-dispatches/webhook-dispatches-collection/get-list-of-webhook-dispatches @@ -63,4 +68,10 @@ async def list( Returns: The retrieved webhook dispatches of a user. """ - return await self._list(limit=limit, offset=offset, desc=desc) + response = await self._http_client.call( + url=self._build_url(), + method='GET', + params=self._build_params(limit=limit, offset=offset, desc=desc), + ) + response_as_dict = response_to_dict(response) + return WebhookDispatchList.model_validate(response_as_dict).data diff --git a/src/apify_client/_statistics.py b/src/apify_client/_statistics.py index d06d8d82..e9962b1c 100644 --- a/src/apify_client/_statistics.py +++ b/src/apify_client/_statistics.py @@ -1,9 +1,13 @@ +"""Statistics about API client usage and rate limit errors.""" + +from __future__ import annotations + from collections import defaultdict from dataclasses import dataclass, field @dataclass -class Statistics: +class ClientStatistics: """Statistics about API client usage and rate limit errors.""" calls: int = 0 diff --git a/src/apify_client/_types.py b/src/apify_client/_types.py deleted file mode 100644 index af1c58f5..00000000 --- a/src/apify_client/_types.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations - -from typing import Any, Generic, TypeVar - -JSONSerializable = str | int | float | bool | None | dict[str, Any] | list[Any] -"""Type for representing json-serializable values. It's close enough to the real thing supported by json.parse. -It was suggested in a discussion with (and approved by) Guido van Rossum, so I'd consider it correct enough. -""" - -T = TypeVar('T') - - -class ListPage(Generic[T]): - """A single page of items returned from a list() method.""" - - items: list[T] - """List of returned objects on this page.""" - - count: int - """Count of the returned objects on this page.""" - - offset: int - """The limit on the number of returned objects offset specified in the API call.""" - - limit: int - """The offset of the first object specified in the API call""" - - total: int - """Total number of objects matching the API call criteria.""" - - desc: bool - """Whether the listing is descending or not.""" - - def __init__(self: ListPage, data: dict) -> None: - """Initialize a ListPage instance from the API response data.""" - self.items = data.get('items', []) - self.offset = data.get('offset', 0) - self.limit = data.get('limit', 0) - self.count = data['count'] if 'count' in data else len(self.items) - self.total = data.get('total', self.offset + self.count) - self.desc = data.get('desc', False) diff --git a/src/apify_client/_utils.py b/src/apify_client/_utils.py index a9d139d5..1b2be8e8 100644 --- a/src/apify_client/_utils.py +++ b/src/apify_client/_utils.py @@ -1,293 +1,257 @@ +"""Utility functions and helpers for the Apify client.""" + from __future__ import annotations -import asyncio import base64 -import contextlib +import hashlib +import hmac import io import json -import json as jsonlib -import random -import re +import string import time -from collections.abc import Callable -from datetime import datetime, timezone from enum import Enum from http import HTTPStatus -from typing import TYPE_CHECKING, Any, TypeVar, cast, overload - -import impit +from typing import TYPE_CHECKING, Any, TypeVar -from apify_client.errors import InvalidResponseBodyError +from apify_client.errors import ApifyApiError # noqa: TC001 - Used at runtime in catch_not_found_or_throw if TYPE_CHECKING: - from collections.abc import Awaitable - from impit import Response - from apify_client.errors import ApifyApiError +T = TypeVar('T') -PARSE_DATE_FIELDS_MAX_DEPTH = 3 -PARSE_DATE_FIELDS_KEY_SUFFIX = 'At' -RECORD_NOT_FOUND_EXCEPTION_TYPES = ['record-not-found', 'record-or-token-not-found'] -T = TypeVar('T') -StopRetryingType = Callable[[], None] +def catch_not_found_or_throw(exc: ApifyApiError) -> None: + """Suppress 404 Not Found errors and re-raise all other API errors. + Args: + exc: The API error to check. -def filter_out_none_values_recursively(dictionary: dict) -> dict: - """Return copy of the dictionary, recursively omitting all keys for which values are None.""" - return cast('dict', filter_out_none_values_recursively_internal(dictionary)) + Raises: + ApifyApiError: If the error is not a 404 Not Found error. + """ + is_not_found_status = exc.status_code == HTTPStatus.NOT_FOUND + is_not_found_type = exc.type in ['record-not-found', 'record-or-token-not-found'] + if not (is_not_found_status and is_not_found_type): + raise exc -def filter_out_none_values_recursively_internal( - dictionary: dict, +def filter_none_values( + data: dict, *, remove_empty_dicts: bool | None = None, -) -> dict | None: - """Recursively filters out None values from a dictionary. +) -> dict: + """Remove None values from a dictionary recursively. - Unfortunately, it's necessary to have an internal function for the correct result typing, - without having to create complicated overloads + The Apify API ignores missing fields but may reject fields explicitly set to None. This function prepares request + payloads by recursively removing None values from nested dictionaries. + + Args: + data: The dictionary to clean. + remove_empty_dicts: If True, also remove empty dictionaries after filtering None values. + + Returns: + A new dictionary with None values removed at all nesting levels. """ - result = {} - for k, v in dictionary.items(): - if isinstance(v, dict): - v = filter_out_none_values_recursively_internal( # noqa: PLW2901 - v, remove_empty_dicts=remove_empty_dicts is True or remove_empty_dicts is None - ) - if v is not None: - result[k] = v - if not result and remove_empty_dicts: - return None - return result + def _internal(dictionary: dict, *, remove_empty: bool | None = None) -> dict | None: + result = {} + for key, val in dictionary.items(): + if isinstance(val, dict): + val = _internal(val, remove_empty=remove_empty) # noqa: PLW2901 + if val is not None: + result[key] = val + if not result and remove_empty: + return None + return result -@overload -def parse_date_fields(data: list, max_depth: int = PARSE_DATE_FIELDS_MAX_DEPTH) -> list: ... + result = _internal(data, remove_empty=remove_empty_dicts) + return result if result is not None else {} -@overload -def parse_date_fields(data: dict, max_depth: int = PARSE_DATE_FIELDS_MAX_DEPTH) -> dict: ... +def encode_webhook_list_to_base64(webhooks: list[dict]) -> str: + """Encode a list of webhook dictionaries to base64 for API transmission. + Args: + webhooks: A list of webhook dictionaries with keys like "event_types", "request_url", etc. -def parse_date_fields(data: list | dict, max_depth: int = PARSE_DATE_FIELDS_MAX_DEPTH) -> list | dict: - """Recursively parse date fields in a list or dictionary up to the specified depth.""" - if max_depth < 0: - return data + Returns: + A base64-encoded JSON string. + """ + data = list[dict]() + for webhook in webhooks: + webhook_representation = { + 'eventTypes': [enum_to_value(event_type) for event_type in webhook['event_types']], + 'requestUrl': webhook['request_url'], + } + if 'payload_template' in webhook: + webhook_representation['payloadTemplate'] = webhook['payload_template'] + if 'headers_template' in webhook: + webhook_representation['headersTemplate'] = webhook['headers_template'] + data.append(webhook_representation) - if isinstance(data, list): - return [parse_date_fields(item, max_depth - 1) for item in data] + return base64.b64encode(json.dumps(data).encode('utf-8')).decode('ascii') - if isinstance(data, dict): - def parse(key: str, value: object) -> object: - parsed_value = value - if key.endswith(PARSE_DATE_FIELDS_KEY_SUFFIX) and isinstance(value, str): - with contextlib.suppress(ValueError): - parsed_value = datetime.strptime(value, '%Y-%m-%dT%H:%M:%S.%fZ').replace(tzinfo=timezone.utc) - elif isinstance(value, dict): - parsed_value = parse_date_fields(value, max_depth - 1) - elif isinstance(value, list): - parsed_value = parse_date_fields(value, max_depth) - return parsed_value +def encode_key_value_store_record_value(value: Any, content_type: str | None = None) -> tuple[Any, str]: + """Encode a value for storage in a key-value store record. - return {key: parse(key, value) for (key, value) in data.items()} + Args: + value: The value to encode (can be dict, str, bytes, or file-like object). + content_type: The content type; if None, it's inferred from the value type. - return data + Returns: + A tuple of (encoded_value, content_type). + """ + if not content_type: + if isinstance(value, (bytes, bytearray, io.IOBase)): + content_type = 'application/octet-stream' + elif isinstance(value, str): + content_type = 'text/plain; charset=utf-8' + else: + content_type = 'application/json; charset=utf-8' + if ( + 'application/json' in content_type + and not isinstance(value, (bytes, bytearray, io.IOBase)) + and not isinstance(value, str) + ): + value = json.dumps(value, ensure_ascii=False, indent=2, allow_nan=False, default=str).encode('utf-8') + + return (value, content_type) -def is_content_type_json(content_type: str) -> bool: - """Check if the given content type is JSON.""" - return bool(re.search(r'^application/json', content_type, flags=re.IGNORECASE)) +def enum_to_value(value: Any) -> Any: + """Convert an Enum member to its value, or return the value unchanged if not an Enum. -def is_content_type_xml(content_type: str) -> bool: - """Check if the given content type is XML.""" - return bool(re.search(r'^application/.*xml$', content_type, flags=re.IGNORECASE)) + Ensures Enum instances are converted to primitive values suitable for API transmission. + Args: + value: The value to potentially convert (Enum member or any other type). + + Returns: + The Enum's value if the input is an Enum; otherwise returns the input unchanged. + """ + if isinstance(value, Enum): + return value.value + return value -def is_content_type_text(content_type: str) -> bool: - """Check if the given content type is text.""" - return bool(re.search(r'^text/', content_type, flags=re.IGNORECASE)) +def to_safe_id(id: str) -> str: + """Convert a resource ID to URL-safe format by replacing forward slashes with tildes. -def is_file_or_bytes(value: Any) -> bool: - """Check if the input value is a file-like object or bytes. + Args: + id: The resource identifier in format `resource_id` or `username/resource_id`. - The check for IOBase is not ideal, it would be better to use duck typing, - but then the check would be super complex, judging from how the 'requests' library does it. - This way should be good enough for the vast majority of use cases, if it causes issues, we can improve it later. + Returns: + The resource identifier with `/` characters replaced by `~`. """ - return isinstance(value, (bytes, bytearray, io.IOBase)) + return id.replace('/', '~') -def json_dumps(obj: Any) -> str: - """Dump JSON to a string with the correct settings and serializer.""" - return json.dumps(obj, ensure_ascii=False, indent=2, default=str) +def response_to_dict(response: Response) -> dict: + """Parse the API response as a dictionary and validate its type. + Args: + response: The HTTP response object from the API. -def maybe_extract_enum_member_value(maybe_enum_member: Any) -> Any: - """Extract the value of an enumeration member if it is an Enum, otherwise return the original value.""" - if isinstance(maybe_enum_member, Enum): - return maybe_enum_member.value - return maybe_enum_member + Returns: + The parsed response as a dictionary. + Raises: + ValueError: If the response is not a dictionary. + """ + data = response.json() + if isinstance(data, dict): + return data -def to_safe_id(id: str) -> str: - # Identificators of resources in the API are either in the format `resource_id` or `username/resource_id`. - # Since the `/` character has a special meaning in URL paths, - # we replace it with `~` for proper route parsing on the API, where after parsing the URL it's replaced back to `/`. - return id.replace('/', '~') + raise ValueError(f'The response is not a dictionary. Got: {type(data).__name__}') -def pluck_data(parsed_response: Any) -> dict: - if isinstance(parsed_response, dict) and 'data' in parsed_response: - return cast('dict', parsed_response['data']) +def response_to_list(response: Response) -> list: + """Parse the API response as a list and validate its type. - raise ValueError('The "data" property is missing in the response.') + Args: + response: The HTTP response object from the API. + Returns: + The parsed response as a list. -def pluck_data_as_list(parsed_response: Any) -> list: - if isinstance(parsed_response, dict) and 'data' in parsed_response: - return cast('list', parsed_response['data']) + Raises: + ValueError: If the response is not a list. + """ + data = response.json() + if isinstance(data, list): + return data - raise ValueError('The "data" property is missing in the response.') + raise ValueError(f'The response is not a list. Got: {type(data).__name__}') -def retry_with_exp_backoff( - func: Callable[[StopRetryingType, int], T], - *, - max_retries: int = 8, - backoff_base_millis: int = 500, - backoff_factor: float = 2, - random_factor: float = 1, -) -> T: - random_factor = min(max(0, random_factor), 1) - backoff_factor = min(max(1, backoff_factor), 10) - swallow = True - - def stop_retrying() -> None: - nonlocal swallow - swallow = False - - for attempt in range(1, max_retries + 1): - try: - return func(stop_retrying, attempt) - except Exception: - if not swallow: - raise - - random_sleep_factor = random.uniform(1, 1 + random_factor) - backoff_base_secs = backoff_base_millis / 1000 - backoff_exp_factor = backoff_factor ** (attempt - 1) - - sleep_time_secs = random_sleep_factor * backoff_base_secs * backoff_exp_factor - time.sleep(sleep_time_secs) - - return func(stop_retrying, max_retries + 1) - - -async def retry_with_exp_backoff_async( - async_func: Callable[[StopRetryingType, int], Awaitable[T]], - *, - max_retries: int = 8, - backoff_base_millis: int = 500, - backoff_factor: float = 2, - random_factor: float = 1, -) -> T: - random_factor = min(max(0, random_factor), 1) - backoff_factor = min(max(1, backoff_factor), 10) - swallow = True +def encode_base62(num: int) -> str: + """Encode an integer to a base62 string. - def stop_retrying() -> None: - nonlocal swallow - swallow = False + Args: + num: The number to encode. - for attempt in range(1, max_retries + 1): - try: - return await async_func(stop_retrying, attempt) - except Exception: - if not swallow: - raise + Returns: + The base62-encoded string. + """ + charset = string.digits + string.ascii_letters - random_sleep_factor = random.uniform(1, 1 + random_factor) - backoff_base_secs = backoff_base_millis / 1000 - backoff_exp_factor = backoff_factor ** (attempt - 1) + if num == 0: + return charset[0] - sleep_time_secs = random_sleep_factor * backoff_base_secs * backoff_exp_factor - await asyncio.sleep(sleep_time_secs) + res = '' + while num > 0: + num, remainder = divmod(num, 62) + res = charset[remainder] + res + return res - return await async_func(stop_retrying, max_retries + 1) +def create_hmac_signature(secret_key: str, message: str) -> str: + """Generate an HMAC-SHA256 signature and encode it using base62. -def catch_not_found_or_throw(exc: ApifyApiError) -> None: - is_not_found_status = exc.status_code == HTTPStatus.NOT_FOUND - is_not_found_type = exc.type in RECORD_NOT_FOUND_EXCEPTION_TYPES - if not (is_not_found_status and is_not_found_type): - raise exc + The HMAC signature is truncated to 30 characters and then encoded in base62 to reduce the signature length. + Args: + secret_key: The secret key used for signing. + message: The message to be signed. -def encode_webhook_list_to_base64(webhooks: list[dict]) -> str: - """Encode a list of dictionaries representing webhooks to their base64-encoded representation for the API.""" - data = [] - for webhook in webhooks: - webhook_representation = { - 'eventTypes': [maybe_extract_enum_member_value(event_type) for event_type in webhook['event_types']], - 'requestUrl': webhook['request_url'], - } - if 'payload_template' in webhook: - webhook_representation['payloadTemplate'] = webhook['payload_template'] - if 'headers_template' in webhook: - webhook_representation['headersTemplate'] = webhook['headers_template'] - data.append(webhook_representation) + Returns: + The base62-encoded signature. + """ + signature = hmac.new(secret_key.encode('utf-8'), message.encode('utf-8'), hashlib.sha256).hexdigest()[:30] - return base64.b64encode(jsonlib.dumps(data).encode('utf-8')).decode('ascii') + decimal_signature = int(signature, 16) + return encode_base62(decimal_signature) -def encode_key_value_store_record_value(value: Any, content_type: str | None = None) -> tuple[Any, str]: - if not content_type: - if is_file_or_bytes(value): - content_type = 'application/octet-stream' - elif isinstance(value, str): - content_type = 'text/plain; charset=utf-8' - else: - content_type = 'application/json; charset=utf-8' - if 'application/json' in content_type and not is_file_or_bytes(value) and not isinstance(value, str): - value = jsonlib.dumps(value, ensure_ascii=False, indent=2, allow_nan=False, default=str).encode('utf-8') +def create_storage_content_signature( + resource_id: str, + url_signing_secret_key: str, + expires_in_millis: int | None = None, + version: int = 0, +) -> str: + """Create a secure signature for a storage resource like a dataset or key-value store. - return (value, content_type) + This signature is used to generate a signed URL for authenticated access, which can be expiring or permanent. + The signature is created using HMAC with the provided secret key and includes the resource ID, expiration time, + and version. + Args: + resource_id: The unique identifier of the storage resource. + url_signing_secret_key: The secret key for signing the URL. + expires_in_millis: Optional expiration time in milliseconds from now; if None, the signature never expires. + version: The signature version number (default: 0). -def maybe_parse_response(response: Response) -> Any: - if response.status_code == HTTPStatus.NO_CONTENT: - return None + Returns: + The base64url-encoded signature string. + """ + expires_at = int(time.time() * 1000) + expires_in_millis if expires_in_millis else 0 - content_type = '' - if 'content-type' in response.headers: - content_type = response.headers['content-type'].split(';')[0].strip() + message_to_sign = f'{version}.{expires_at}.{resource_id}' + hmac_sig = create_hmac_signature(url_signing_secret_key, message_to_sign) - try: - if is_content_type_json(content_type): - response_data = response.json() - elif is_content_type_xml(content_type) or is_content_type_text(content_type): - response_data = response.text - else: - response_data = response.content - except ValueError as err: - raise InvalidResponseBodyError(response) from err - else: - return response_data - - -def is_retryable_error(exc: Exception) -> bool: - """Check if the given error is retryable.""" - return isinstance( - exc, - ( - InvalidResponseBodyError, - impit.NetworkError, - impit.TimeoutException, - impit.RemoteProtocolError, - ), - ) + base64url_encoded_payload = base64.urlsafe_b64encode(f'{version}.{expires_at}.{hmac_sig}'.encode()) + return base64url_encoded_payload.decode('utf-8') diff --git a/src/apify_client/clients/__init__.py b/src/apify_client/clients/__init__.py deleted file mode 100644 index 6f1fdaaa..00000000 --- a/src/apify_client/clients/__init__.py +++ /dev/null @@ -1,131 +0,0 @@ -from .base import ( - ActorJobBaseClient, - ActorJobBaseClientAsync, - BaseClient, - BaseClientAsync, - ResourceClient, - ResourceClientAsync, - ResourceCollectionClient, - ResourceCollectionClientAsync, -) -from .resource_clients import ( - ActorClient, - ActorClientAsync, - ActorCollectionClient, - ActorCollectionClientAsync, - ActorEnvVarClient, - ActorEnvVarClientAsync, - ActorEnvVarCollectionClient, - ActorEnvVarCollectionClientAsync, - ActorVersionClient, - ActorVersionClientAsync, - ActorVersionCollectionClient, - ActorVersionCollectionClientAsync, - BuildClient, - BuildClientAsync, - BuildCollectionClient, - BuildCollectionClientAsync, - DatasetClient, - DatasetClientAsync, - DatasetCollectionClient, - DatasetCollectionClientAsync, - KeyValueStoreClient, - KeyValueStoreClientAsync, - KeyValueStoreCollectionClient, - KeyValueStoreCollectionClientAsync, - LogClient, - LogClientAsync, - RequestQueueClient, - RequestQueueClientAsync, - RequestQueueCollectionClient, - RequestQueueCollectionClientAsync, - RunClient, - RunClientAsync, - RunCollectionClient, - RunCollectionClientAsync, - ScheduleClient, - ScheduleClientAsync, - ScheduleCollectionClient, - ScheduleCollectionClientAsync, - StoreCollectionClient, - StoreCollectionClientAsync, - TaskClient, - TaskClientAsync, - TaskCollectionClient, - TaskCollectionClientAsync, - UserClient, - UserClientAsync, - WebhookClient, - WebhookClientAsync, - WebhookCollectionClient, - WebhookCollectionClientAsync, - WebhookDispatchClient, - WebhookDispatchClientAsync, - WebhookDispatchCollectionClient, - WebhookDispatchCollectionClientAsync, -) - -__all__ = [ - 'ActorClient', - 'ActorClientAsync', - 'ActorCollectionClient', - 'ActorCollectionClientAsync', - 'ActorEnvVarClient', - 'ActorEnvVarClientAsync', - 'ActorEnvVarCollectionClient', - 'ActorEnvVarCollectionClientAsync', - 'ActorJobBaseClient', - 'ActorJobBaseClientAsync', - 'ActorVersionClient', - 'ActorVersionClientAsync', - 'ActorVersionCollectionClient', - 'ActorVersionCollectionClientAsync', - 'BaseClient', - 'BaseClientAsync', - 'BuildClient', - 'BuildClientAsync', - 'BuildCollectionClient', - 'BuildCollectionClientAsync', - 'DatasetClient', - 'DatasetClientAsync', - 'DatasetCollectionClient', - 'DatasetCollectionClientAsync', - 'KeyValueStoreClient', - 'KeyValueStoreClientAsync', - 'KeyValueStoreCollectionClient', - 'KeyValueStoreCollectionClientAsync', - 'LogClient', - 'LogClientAsync', - 'RequestQueueClient', - 'RequestQueueClientAsync', - 'RequestQueueCollectionClient', - 'RequestQueueCollectionClientAsync', - 'ResourceClient', - 'ResourceClientAsync', - 'ResourceCollectionClient', - 'ResourceCollectionClientAsync', - 'RunClient', - 'RunClientAsync', - 'RunCollectionClient', - 'RunCollectionClientAsync', - 'ScheduleClient', - 'ScheduleClientAsync', - 'ScheduleCollectionClient', - 'ScheduleCollectionClientAsync', - 'StoreCollectionClient', - 'StoreCollectionClientAsync', - 'TaskClient', - 'TaskClientAsync', - 'TaskCollectionClient', - 'TaskCollectionClientAsync', - 'UserClient', - 'UserClientAsync', - 'WebhookClient', - 'WebhookClientAsync', - 'WebhookCollectionClient', - 'WebhookCollectionClientAsync', - 'WebhookDispatchClient', - 'WebhookDispatchClientAsync', - 'WebhookDispatchCollectionClient', - 'WebhookDispatchCollectionClientAsync', -] diff --git a/src/apify_client/clients/base/__init__.py b/src/apify_client/clients/base/__init__.py deleted file mode 100644 index 27d879c9..00000000 --- a/src/apify_client/clients/base/__init__.py +++ /dev/null @@ -1,15 +0,0 @@ -from .actor_job_base_client import ActorJobBaseClient, ActorJobBaseClientAsync -from .base_client import BaseClient, BaseClientAsync -from .resource_client import ResourceClient, ResourceClientAsync -from .resource_collection_client import ResourceCollectionClient, ResourceCollectionClientAsync - -__all__ = [ - 'ActorJobBaseClient', - 'ActorJobBaseClientAsync', - 'BaseClient', - 'BaseClientAsync', - 'ResourceClient', - 'ResourceClientAsync', - 'ResourceCollectionClient', - 'ResourceCollectionClientAsync', -] diff --git a/src/apify_client/clients/base/actor_job_base_client.py b/src/apify_client/clients/base/actor_job_base_client.py deleted file mode 100644 index abf7e274..00000000 --- a/src/apify_client/clients/base/actor_job_base_client.py +++ /dev/null @@ -1,125 +0,0 @@ -from __future__ import annotations - -import asyncio -import math -import time -from datetime import datetime, timezone - -from apify_shared.consts import ActorJobStatus - -from apify_client._utils import catch_not_found_or_throw, parse_date_fields, pluck_data -from apify_client.clients.base.resource_client import ResourceClient, ResourceClientAsync -from apify_client.errors import ApifyApiError - -DEFAULT_WAIT_FOR_FINISH_SEC = 999999 - -# After how many seconds we give up trying in case job doesn't exist -DEFAULT_WAIT_WHEN_JOB_NOT_EXIST_SEC = 3 - - -class ActorJobBaseClient(ResourceClient): - """Base sub-client class for Actor runs and Actor builds.""" - - def _wait_for_finish(self, wait_secs: int | None = None) -> dict | None: - started_at = datetime.now(timezone.utc) - should_repeat = True - job: dict | None = None - seconds_elapsed = 0 - - while should_repeat: - wait_for_finish = DEFAULT_WAIT_FOR_FINISH_SEC - if wait_secs is not None: - wait_for_finish = wait_secs - seconds_elapsed - - try: - response = self.http_client.call( - url=self._url(), - method='GET', - params=self._params(waitForFinish=wait_for_finish), - ) - job = parse_date_fields(pluck_data(response.json())) - - seconds_elapsed = math.floor((datetime.now(timezone.utc) - started_at).total_seconds()) - if ActorJobStatus(job['status']).is_terminal or ( - wait_secs is not None and seconds_elapsed >= wait_secs - ): - should_repeat = False - - if not should_repeat: - # Early return here so that we avoid the sleep below if not needed - return job - - except ApifyApiError as exc: - catch_not_found_or_throw(exc) - - # If there are still not found errors after DEFAULT_WAIT_WHEN_JOB_NOT_EXIST_SEC, we give up - # and return None. In such case, the requested record probably really doesn't exist. - if seconds_elapsed > DEFAULT_WAIT_WHEN_JOB_NOT_EXIST_SEC: - return None - - # It might take some time for database replicas to get up-to-date so sleep a bit before retrying - time.sleep(0.25) - - return job - - def _abort(self, *, gracefully: bool | None = None) -> dict: - response = self.http_client.call( - url=self._url('abort'), - method='POST', - params=self._params(gracefully=gracefully), - ) - return parse_date_fields(pluck_data(response.json())) - - -class ActorJobBaseClientAsync(ResourceClientAsync): - """Base async sub-client class for Actor runs and Actor builds.""" - - async def _wait_for_finish(self, wait_secs: int | None = None) -> dict | None: - started_at = datetime.now(timezone.utc) - should_repeat = True - job: dict | None = None - seconds_elapsed = 0 - - while should_repeat: - wait_for_finish = DEFAULT_WAIT_FOR_FINISH_SEC - if wait_secs is not None: - wait_for_finish = wait_secs - seconds_elapsed - - try: - response = await self.http_client.call( - url=self._url(), - method='GET', - params=self._params(waitForFinish=wait_for_finish), - ) - job = parse_date_fields(pluck_data(response.json())) - - seconds_elapsed = math.floor((datetime.now(timezone.utc) - started_at).total_seconds()) - if ActorJobStatus(job['status']).is_terminal or ( - wait_secs is not None and seconds_elapsed >= wait_secs - ): - should_repeat = False - - if not should_repeat: - # Early return here so that we avoid the sleep below if not needed - return job - - except ApifyApiError as exc: - catch_not_found_or_throw(exc) - - # If there are still not found errors after DEFAULT_WAIT_WHEN_JOB_NOT_EXIST_SEC, we give up - # and return None. In such case, the requested record probably really doesn't exist. - if seconds_elapsed > DEFAULT_WAIT_WHEN_JOB_NOT_EXIST_SEC: - return None - - # It might take some time for database replicas to get up-to-date so sleep a bit before retrying - await asyncio.sleep(0.25) - - return job - - async def _abort(self, *, gracefully: bool | None = None) -> dict: - response = await self.http_client.call( - url=self._url('abort'), - method='POST', - params=self._params(gracefully=gracefully), - ) - return parse_date_fields(pluck_data(response.json())) diff --git a/src/apify_client/clients/base/base_client.py b/src/apify_client/clients/base/base_client.py deleted file mode 100644 index c5aa744c..00000000 --- a/src/apify_client/clients/base/base_client.py +++ /dev/null @@ -1,129 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Any - -from apify_client._logging import WithLogDetailsClient -from apify_client._utils import to_safe_id - -# Conditional import only executed when type checking, otherwise we'd get circular dependency issues -if TYPE_CHECKING: - from apify_client import ApifyClient, ApifyClientAsync - from apify_client._http_client import HTTPClient, HTTPClientAsync - - -class _BaseBaseClient(metaclass=WithLogDetailsClient): - resource_id: str | None - url: str - params: dict - http_client: HTTPClient | HTTPClientAsync - root_client: ApifyClient | ApifyClientAsync - - def _url(self, path: str | None = None, *, public: bool = False) -> str: - url = f'{self.url}/{path}' if path is not None else self.url - - if public: - if not url.startswith(self.root_client.base_url): - raise ValueError('API based URL has to start with `self.root_client.base_url`') - return url.replace(self.root_client.base_url, self.root_client.public_base_url, 1) - return url - - def _params(self, **kwargs: Any) -> dict: - return { - **self.params, - **kwargs, - } - - def _sub_resource_init_options(self, **kwargs: Any) -> dict: - options = { - 'base_url': self.url, - 'http_client': self.http_client, - 'params': self.params, - 'root_client': self.root_client, - } - - return { - **options, - **kwargs, - } - - -class BaseClient(_BaseBaseClient): - """Base class for sub-clients.""" - - http_client: HTTPClient - root_client: ApifyClient - - def __init__( - self, - *, - base_url: str, - root_client: ApifyClient, - http_client: HTTPClient, - resource_id: str | None = None, - resource_path: str, - params: dict | None = None, - ) -> None: - """Initialize a new instance. - - Args: - base_url: Base URL of the API server. - root_client: The ApifyClient instance under which this resource client exists. - http_client: The HTTPClient instance to be used in this client. - resource_id: ID of the manipulated resource, in case of a single-resource client. - resource_path: Path to the resource's endpoint on the API server. - params: Parameters to include in all requests from this client. - """ - if resource_path.endswith('/'): - raise ValueError('resource_path must not end with "/"') - - self.base_url = base_url - self.root_client = root_client - self.http_client = http_client - self.params = params or {} - self.resource_path = resource_path - self.resource_id = resource_id - self.url = f'{self.base_url}/{self.resource_path}' - if self.resource_id is not None: - self.safe_id = to_safe_id(self.resource_id) - self.url = f'{self.url}/{self.safe_id}' - - -class BaseClientAsync(_BaseBaseClient): - """Base class for async sub-clients.""" - - http_client: HTTPClientAsync - root_client: ApifyClientAsync - - def __init__( - self, - *, - base_url: str, - root_client: ApifyClientAsync, - http_client: HTTPClientAsync, - resource_id: str | None = None, - resource_path: str, - params: dict | None = None, - ) -> None: - """Initialize a new instance. - - Args: - base_url: Base URL of the API server. - root_client: The ApifyClientAsync instance under which this resource client exists. - http_client: The HTTPClientAsync instance to be used in this client. - resource_id: ID of the manipulated resource, in case of a single-resource client. - resource_path: Path to the resource's endpoint on the API server. - params: Parameters to include in all requests from this client. - """ - if resource_path.endswith('/'): - raise ValueError('resource_path must not end with "/"') - - self.base_url = base_url - self.root_client = root_client - self.http_client = http_client - self.params = params or {} - self.resource_path = resource_path - self.resource_id = resource_id - self.url = f'{self.base_url}/{self.resource_path}' - if self.resource_id is not None: - self.safe_id = to_safe_id(self.resource_id) - self.url = f'{self.url}/{self.safe_id}' diff --git a/src/apify_client/clients/base/resource_client.py b/src/apify_client/clients/base/resource_client.py deleted file mode 100644 index ddf30adf..00000000 --- a/src/apify_client/clients/base/resource_client.py +++ /dev/null @@ -1,91 +0,0 @@ -from __future__ import annotations - -from apify_client._utils import catch_not_found_or_throw, parse_date_fields, pluck_data -from apify_client.clients.base.base_client import BaseClient, BaseClientAsync -from apify_client.errors import ApifyApiError - - -class ResourceClient(BaseClient): - """Base class for sub-clients manipulating a single resource.""" - - def _get(self, timeout_secs: int | None = None) -> dict | None: - try: - response = self.http_client.call( - url=self.url, - method='GET', - params=self._params(), - timeout_secs=timeout_secs, - ) - - return parse_date_fields(pluck_data(response.json())) - - except ApifyApiError as exc: - catch_not_found_or_throw(exc) - - return None - - def _update(self, updated_fields: dict, timeout_secs: int | None = None) -> dict: - response = self.http_client.call( - url=self._url(), - method='PUT', - params=self._params(), - json=updated_fields, - timeout_secs=timeout_secs, - ) - - return parse_date_fields(pluck_data(response.json())) - - def _delete(self, timeout_secs: int | None = None) -> None: - try: - self.http_client.call( - url=self._url(), - method='DELETE', - params=self._params(), - timeout_secs=timeout_secs, - ) - - except ApifyApiError as exc: - catch_not_found_or_throw(exc) - - -class ResourceClientAsync(BaseClientAsync): - """Base class for async sub-clients manipulating a single resource.""" - - async def _get(self, timeout_secs: int | None = None) -> dict | None: - try: - response = await self.http_client.call( - url=self.url, - method='GET', - params=self._params(), - timeout_secs=timeout_secs, - ) - - return parse_date_fields(pluck_data(response.json())) - - except ApifyApiError as exc: - catch_not_found_or_throw(exc) - - return None - - async def _update(self, updated_fields: dict, timeout_secs: int | None = None) -> dict: - response = await self.http_client.call( - url=self._url(), - method='PUT', - params=self._params(), - json=updated_fields, - timeout_secs=timeout_secs, - ) - - return parse_date_fields(pluck_data(response.json())) - - async def _delete(self, timeout_secs: int | None = None) -> None: - try: - await self.http_client.call( - url=self._url(), - method='DELETE', - params=self._params(), - timeout_secs=timeout_secs, - ) - - except ApifyApiError as exc: - catch_not_found_or_throw(exc) diff --git a/src/apify_client/clients/base/resource_collection_client.py b/src/apify_client/clients/base/resource_collection_client.py deleted file mode 100644 index 2e9c6063..00000000 --- a/src/apify_client/clients/base/resource_collection_client.py +++ /dev/null @@ -1,109 +0,0 @@ -from __future__ import annotations - -from typing import Any, Generic, TypeVar - -from apify_client._utils import parse_date_fields, pluck_data -from apify_client.clients.base.base_client import BaseClient, BaseClientAsync - -T = TypeVar('T') - - -class ListPage(Generic[T]): - """A single page of items returned from a list() method.""" - - items: list[T] - """List of returned objects on this page""" - - count: int - """Count of the returned objects on this page""" - - offset: int - """The limit on the number of returned objects offset specified in the API call""" - - limit: int - """The offset of the first object specified in the API call""" - - total: int - """Total number of objects matching the API call criteria""" - - desc: bool - """Whether the listing is descending or not""" - - def __init__(self, data: dict) -> None: - """Initialize a ListPage instance from the API response data.""" - self.items = data.get('items', []) - self.offset = data.get('offset', 0) - self.limit = data.get('limit', 0) - self.count = data['count'] if 'count' in data else len(self.items) - self.total = data['total'] if 'total' in data else self.offset + self.count - self.desc = data.get('desc', False) - - -class ResourceCollectionClient(BaseClient): - """Base class for sub-clients manipulating a resource collection.""" - - def _list(self, **kwargs: Any) -> ListPage: - response = self.http_client.call( - url=self._url(), - method='GET', - params=self._params(**kwargs), - ) - - return ListPage(parse_date_fields(pluck_data(response.json()))) - - def _create(self, resource: dict) -> dict: - response = self.http_client.call( - url=self._url(), - method='POST', - params=self._params(), - json=resource, - ) - - return parse_date_fields(pluck_data(response.json())) - - def _get_or_create(self, name: str | None = None, resource: dict | None = None) -> dict: - response = self.http_client.call( - url=self._url(), - method='POST', - params=self._params(name=name), - json=resource, - ) - - return parse_date_fields(pluck_data(response.json())) - - -class ResourceCollectionClientAsync(BaseClientAsync): - """Base class for async sub-clients manipulating a resource collection.""" - - async def _list(self, **kwargs: Any) -> ListPage: - response = await self.http_client.call( - url=self._url(), - method='GET', - params=self._params(**kwargs), - ) - - return ListPage(parse_date_fields(pluck_data(response.json()))) - - async def _create(self, resource: dict) -> dict: - response = await self.http_client.call( - url=self._url(), - method='POST', - params=self._params(), - json=resource, - ) - - return parse_date_fields(pluck_data(response.json())) - - async def _get_or_create( - self, - name: str | None = None, - resource: dict | None = None, - ) -> dict: - response = await self.http_client.call( - url=self._url(), - method='POST', - params=self._params(name=name), - json=resource, - ) - - return parse_date_fields(pluck_data(response.json())) diff --git a/src/apify_client/clients/resource_clients/build.py b/src/apify_client/clients/resource_clients/build.py deleted file mode 100644 index e4f7d6cf..00000000 --- a/src/apify_client/clients/resource_clients/build.py +++ /dev/null @@ -1,158 +0,0 @@ -from __future__ import annotations - -from typing import Any - -from apify_client.clients.base import ActorJobBaseClient, ActorJobBaseClientAsync -from apify_client.clients.resource_clients.log import LogClient, LogClientAsync - - -class BuildClient(ActorJobBaseClient): - """Sub-client for manipulating a single Actor build.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - resource_path = kwargs.pop('resource_path', 'actor-builds') - super().__init__(*args, resource_path=resource_path, **kwargs) - - def get(self) -> dict | None: - """Return information about the Actor build. - - https://docs.apify.com/api/v2#/reference/actor-builds/build-object/get-build - - Returns: - The retrieved Actor build data. - """ - return self._get() - - def delete(self) -> None: - """Delete the build. - - https://docs.apify.com/api/v2#/reference/actor-builds/delete-build/delete-build - """ - return self._delete() - - def abort(self) -> dict: - """Abort the Actor build which is starting or currently running and return its details. - - https://docs.apify.com/api/v2#/reference/actor-builds/abort-build/abort-build - - Returns: - The data of the aborted Actor build. - """ - return self._abort() - - def get_open_api_definition(self) -> dict | None: - """Return OpenAPI definition of the Actor's build. - - https://docs.apify.com/api/v2/actor-build-openapi-json-get - - Returns: - OpenAPI definition of the Actor's build. - """ - response = self.http_client.call( - url=self._url('openapi.json'), - method='GET', - ) - - response_data: dict = response.json() - - return response_data - - def wait_for_finish(self, *, wait_secs: int | None = None) -> dict | None: - """Wait synchronously until the build finishes or the server times out. - - Args: - wait_secs: How long does the client wait for build to finish. None for indefinite. - - Returns: - The Actor build data. If the status on the object is not one of the terminal statuses (SUCCEEDED, FAILED, - TIMED_OUT, ABORTED), then the build has not yet finished. - """ - return self._wait_for_finish(wait_secs=wait_secs) - - def log(self) -> LogClient: - """Get the client for the log of the Actor build. - - https://docs.apify.com/api/v2/#/reference/actor-builds/build-log/get-log - - Returns: - A client allowing access to the log of this Actor build. - """ - return LogClient( - **self._sub_resource_init_options(resource_path='log'), - ) - - -class BuildClientAsync(ActorJobBaseClientAsync): - """Async sub-client for manipulating a single Actor build.""" - - def __init__(self, *args: Any, **kwargs: Any) -> None: - resource_path = kwargs.pop('resource_path', 'actor-builds') - super().__init__(*args, resource_path=resource_path, **kwargs) - - async def get(self) -> dict | None: - """Return information about the Actor build. - - https://docs.apify.com/api/v2#/reference/actor-builds/build-object/get-build - - Returns: - The retrieved Actor build data. - """ - return await self._get() - - async def abort(self) -> dict: - """Abort the Actor build which is starting or currently running and return its details. - - https://docs.apify.com/api/v2#/reference/actor-builds/abort-build/abort-build - - Returns: - The data of the aborted Actor build. - """ - return await self._abort() - - async def delete(self) -> None: - """Delete the build. - - https://docs.apify.com/api/v2#/reference/actor-builds/delete-build/delete-build - """ - return await self._delete() - - async def get_open_api_definition(self) -> dict | None: - """Return OpenAPI definition of the Actor's build. - - https://docs.apify.com/api/v2/actor-build-openapi-json-get - - Returns: - OpenAPI definition of the Actor's build. - """ - response = await self.http_client.call( - url=self._url('openapi.json'), - method='GET', - ) - - response_data: dict = response.json() - - return response_data - - async def wait_for_finish(self, *, wait_secs: int | None = None) -> dict | None: - """Wait synchronously until the build finishes or the server times out. - - Args: - wait_secs: How long does the client wait for build to finish. None for indefinite. - - Returns: - The Actor build data. If the status on the object is not one of the terminal statuses (SUCCEEDED, FAILED, - TIMED_OUT, ABORTED), then the build has not yet finished. - """ - return await self._wait_for_finish(wait_secs=wait_secs) - - def log(self) -> LogClientAsync: - """Get the client for the log of the Actor build. - - https://docs.apify.com/api/v2/#/reference/actor-builds/build-log/get-log - - Returns: - A client allowing access to the log of this Actor build. - """ - return LogClientAsync( - **self._sub_resource_init_options(resource_path='log'), - ) diff --git a/src/apify_client/errors.py b/src/apify_client/errors.py index 1b83003c..3b6b0801 100644 --- a/src/apify_client/errors.py +++ b/src/apify_client/errors.py @@ -11,20 +11,19 @@ class ApifyClientError(Exception): class ApifyApiError(ApifyClientError): - """Error specific to requests to the Apify API. + """Error from Apify API responses (rate limits, validation errors, internal errors). - An `ApifyApiError` is thrown for successful HTTP requests that reach the API, but the API responds with - an error response. Typically, those are rate limit errors and internal errors, which are automatically retried, - or validation errors, which are thrown immediately, because a correction by the user is needed. + Thrown when HTTP request succeeds but API returns an error response. Rate limit and internal errors are + retried automatically, while validation errors are thrown immediately for user correction. """ def __init__(self, response: impit.Response, attempt: int, method: str = 'GET') -> None: - """Initialize a new instance. + """Initialize an API error from a failed response. Args: - response: The response to the failed API call. - attempt: Which attempt was the request that failed. - method: The HTTP method used for the request. + response: The failed API response. + attempt: The attempt number when the request failed. + method: The HTTP method used. """ self.message: str | None = None self.type: str | None = None @@ -33,7 +32,7 @@ def __init__(self, response: impit.Response, attempt: int, method: str = 'GET') self.message = f'Unexpected error: {response.text}' try: response_data = response.json() - if 'error' in response_data: + if isinstance(response_data, dict) and 'error' in response_data: self.message = response_data['error']['message'] self.type = response_data['error']['type'] if 'data' in response_data['error']: @@ -48,25 +47,18 @@ def __init__(self, response: impit.Response, attempt: int, method: str = 'GET') self.attempt = attempt self.http_method = method - # TODO: self.client_method # noqa: TD003 - # TODO: self.original_stack # noqa: TD003 - # TODO: self.path # noqa: TD003 - # TODO: self.stack # noqa: TD003 - class InvalidResponseBodyError(ApifyClientError): - """Error caused by the response body failing to be parsed. + """Error when response body cannot be parsed (e.g., partial JSON). - This error exists for the quite common situation, where only a partial JSON response is received and an attempt - to parse the JSON throws an error. In most cases this can be resolved by retrying the request. We do that by - identifying this error in the HTTPClient. + Commonly occurs when only partial JSON is received. Usually resolved by retrying the request. """ def __init__(self, response: impit.Response) -> None: """Initialize a new instance. Args: - response: The response which failed to be parsed. + response: The response that failed to parse. """ super().__init__('Response body could not be parsed') diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 5e1d4de1..332d41df 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,25 +1,18 @@ import json import os -import secrets from collections.abc import Generator import pytest -from apify_shared.utils import create_hmac_signature, create_storage_content_signature -from .integration_test_utils import TestDataset, TestKvs +from .utils import DatasetFixture, KvsFixture, get_crypto_random_object_id from apify_client import ApifyClient, ApifyClientAsync +from apify_client._utils import create_hmac_signature, create_storage_content_signature TOKEN_ENV_VAR = 'APIFY_TEST_USER_API_TOKEN' TOKEN_ENV_VAR_2 = 'APIFY_TEST_USER_2_API_TOKEN' API_URL_ENV_VAR = 'APIFY_INTEGRATION_TESTS_API_URL' -def crypto_random_object_id(length: int = 17) -> str: - """Generate a random object ID.""" - chars = 'abcdefghijklmnopqrstuvwxyzABCEDFGHIJKLMNOPQRSTUVWXYZ0123456789' - return ''.join(secrets.choice(chars) for _ in range(length)) - - @pytest.fixture(scope='session') def api_token() -> str: token = os.getenv(TOKEN_ENV_VAR) @@ -53,25 +46,28 @@ def apify_client_async(api_token: str) -> ApifyClientAsync: @pytest.fixture(scope='session') -def test_dataset_of_another_user(api_token_2: str) -> Generator[TestDataset]: +def test_dataset_of_another_user(api_token_2: str) -> Generator[DatasetFixture]: """Pre-existing named dataset of another test user with restricted access.""" client = ApifyClient(api_token_2, api_url=os.getenv(API_URL_ENV_VAR)) - dataset_name = f'API-test-permissions-{crypto_random_object_id()}' + dataset_name = f'API-test-permissions-{get_crypto_random_object_id()}' dataset = client.datasets().get_or_create(name=dataset_name) - dataset_client = client.dataset(dataset_id=dataset['id']) + dataset_client = client.dataset(dataset_id=dataset.id) expected_content = [{'item1': 1, 'item2': 2, 'item3': 3}, {'item1': 4, 'item2': 5, 'item3': 6}] # Push data to dataset dataset_client.push_items(json.dumps(expected_content)) + assert dataset.url_signing_secret_key is not None + # Generate signature for the test signature = create_storage_content_signature( - resource_id=dataset['id'], url_signing_secret_key=dataset['urlSigningSecretKey'] + resource_id=dataset.id, + url_signing_secret_key=dataset.url_signing_secret_key, ) - yield TestDataset( - id=dataset['id'], + yield DatasetFixture( + id=dataset.id, signature=signature, expected_content=[{'item1': 1, 'item2': 2, 'item3': 3}, {'item1': 4, 'item2': 5, 'item3': 6}], ) @@ -80,13 +76,13 @@ def test_dataset_of_another_user(api_token_2: str) -> Generator[TestDataset]: @pytest.fixture(scope='session') -def test_kvs_of_another_user(api_token_2: str) -> Generator[TestKvs]: +def test_kvs_of_another_user(api_token_2: str) -> Generator[KvsFixture]: """Pre-existing named key value store of another test user with restricted access.""" client = ApifyClient(api_token_2, api_url=os.getenv(API_URL_ENV_VAR)) - kvs_name = f'API-test-permissions-{crypto_random_object_id()}' + kvs_name = f'API-test-permissions-{get_crypto_random_object_id()}' kvs = client.key_value_stores().get_or_create(name=kvs_name) - kvs_client = client.key_value_store(key_value_store_id=kvs['id']) + kvs_client = client.key_value_store(key_value_store_id=kvs.id) expected_content = {'key1': 1, 'key2': 2, 'key3': 3} # Push data to kvs @@ -95,14 +91,14 @@ def test_kvs_of_another_user(api_token_2: str) -> Generator[TestKvs]: # Generate signature for the test signature = create_storage_content_signature( - resource_id=kvs['id'], url_signing_secret_key=kvs['urlSigningSecretKey'] + resource_id=kvs.id, url_signing_secret_key=kvs.url_signing_secret_key or '' ) - yield TestKvs( - id=kvs['id'], + yield KvsFixture( + id=kvs.id, signature=signature, expected_content=expected_content, - keys_signature={key: create_hmac_signature(kvs['urlSigningSecretKey'], key) for key in expected_content}, + keys_signature={key: create_hmac_signature(kvs.url_signing_secret_key or '', key) for key in expected_content}, ) kvs_client.delete() diff --git a/tests/integration/test_actor.py b/tests/integration/test_actor.py new file mode 100644 index 00000000..9551802d --- /dev/null +++ b/tests/integration/test_actor.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .utils import get_random_resource_name + +if TYPE_CHECKING: + from apify_client import ApifyClient + + +def test_get_public_actor(apify_client: ApifyClient) -> None: + """Test getting a public actor by ID.""" + # Use a well-known public actor (Apify's web scraper) + actor = apify_client.actor('apify/web-scraper').get() + + assert actor is not None + assert actor.id is not None + assert actor.name == 'web-scraper' + assert actor.username == 'apify' + + +def test_get_actor_by_full_name(apify_client: ApifyClient) -> None: + """Test getting an actor using username/actorname format.""" + actor = apify_client.actor('apify/hello-world').get() + + assert actor is not None + assert actor.name == 'hello-world' + assert actor.username == 'apify' + + +def test_list_actors_my(apify_client: ApifyClient) -> None: + """Test listing actors created by the user.""" + actors_page = apify_client.actors().list(my=True, limit=10) + + assert actors_page is not None + assert actors_page.items is not None + # User may have 0 actors + assert isinstance(actors_page.items, list) + + +def test_list_actors_pagination(apify_client: ApifyClient) -> None: + """Test listing actors with pagination parameters.""" + # List all actors (public + owned), should return some results + actors_page = apify_client.actors().list(limit=5, offset=0) + + assert actors_page is not None + assert actors_page.items is not None + assert isinstance(actors_page.items, list) + # Should have at least some actors (public ones exist) + assert len(actors_page.items) >= 0 + + +def test_list_actors_sorting(apify_client: ApifyClient) -> None: + """Test listing actors with sorting.""" + actors_page = apify_client.actors().list(limit=10, desc=True, sort_by='createdAt') + + assert actors_page is not None + assert actors_page.items is not None + assert isinstance(actors_page.items, list) + + +def test_actor_create_update_delete(apify_client: ApifyClient) -> None: + """Test creating, updating, and deleting an actor.""" + actor_name = get_random_resource_name('actor') + + # Create actor + created_actor = apify_client.actors().create( + name=actor_name, + title='Test Actor', + description='Test actor for integration tests', + versions=[ + { + 'versionNumber': '0.1', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello")', + } + ], + } + ], + ) + assert created_actor is not None + assert created_actor.id is not None + assert created_actor.name == actor_name + + actor_client = apify_client.actor(created_actor.id) + + try: + # Update actor (only title and description - updating defaultRunOptions requires build to be set) + new_title = 'Updated Test Actor' + new_description = 'Updated description' + updated_actor = actor_client.update( + title=new_title, + description=new_description, + ) + assert updated_actor is not None + assert updated_actor.title == new_title + assert updated_actor.description == new_description + + # Verify update persisted + retrieved_actor = actor_client.get() + assert retrieved_actor is not None + assert retrieved_actor.title == new_title + + finally: + # Cleanup - delete actor + actor_client.delete() + + # Verify deletion + deleted_actor = actor_client.get() + assert deleted_actor is None + + +def test_actor_default_build(apify_client: ApifyClient) -> None: + """Test getting an actor's default build.""" + # Use a public actor that has builds + actor_client = apify_client.actor('apify/hello-world') + + # Get default build client + build_client = actor_client.default_build() + assert build_client is not None + + # Use the returned client to get the build + build = build_client.get() + assert build is not None + assert build.id is not None + assert build.status is not None + + +def test_actor_last_run(apify_client: ApifyClient) -> None: + """Test getting an actor's last run.""" + # First run an actor to ensure there is a last run + actor_client = apify_client.actor('apify/hello-world') + run = actor_client.call() + assert run is not None + + try: + # Get last run client + last_run_client = actor_client.last_run() + assert last_run_client is not None + + # Use the returned client to get the run + last_run = last_run_client.get() + assert last_run is not None + assert last_run.id is not None + + finally: + # Cleanup + apify_client.run(run.id).delete() + + +def test_actor_validate_input(apify_client: ApifyClient) -> None: + """Test validating actor input.""" + # Use a public actor with an input schema + actor_client = apify_client.actor('apify/hello-world') + + # Valid input (hello-world accepts empty input or simple input) + is_valid = actor_client.validate_input({}) + assert is_valid is True diff --git a/tests/integration/test_actor_async.py b/tests/integration/test_actor_async.py new file mode 100644 index 00000000..0b64fc43 --- /dev/null +++ b/tests/integration/test_actor_async.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .utils import get_random_resource_name + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + + +async def test_get_public_actor(apify_client_async: ApifyClientAsync) -> None: + """Test getting a public actor by ID.""" + # Use a well-known public actor (Apify's web scraper) + actor = await apify_client_async.actor('apify/web-scraper').get() + + assert actor is not None + assert actor.id is not None + assert actor.name == 'web-scraper' + assert actor.username == 'apify' + + +async def test_get_actor_by_full_name(apify_client_async: ApifyClientAsync) -> None: + """Test getting an actor using username/actorname format.""" + actor = await apify_client_async.actor('apify/hello-world').get() + + assert actor is not None + assert actor.name == 'hello-world' + assert actor.username == 'apify' + + +async def test_list_actors_my(apify_client_async: ApifyClientAsync) -> None: + """Test listing actors created by the user.""" + actors_page = await apify_client_async.actors().list(my=True, limit=10) + + assert actors_page is not None + assert actors_page.items is not None + # User may have 0 actors + assert isinstance(actors_page.items, list) + + +async def test_list_actors_pagination(apify_client_async: ApifyClientAsync) -> None: + """Test listing actors with pagination parameters.""" + # List all actors (public + owned), should return some results + actors_page = await apify_client_async.actors().list(limit=5, offset=0) + + assert actors_page is not None + assert actors_page.items is not None + assert isinstance(actors_page.items, list) + # Should have at least some actors (public ones exist) + assert len(actors_page.items) >= 0 + + +async def test_list_actors_sorting(apify_client_async: ApifyClientAsync) -> None: + """Test listing actors with sorting.""" + actors_page = await apify_client_async.actors().list(limit=10, desc=True, sort_by='createdAt') + + assert actors_page is not None + assert actors_page.items is not None + assert isinstance(actors_page.items, list) + + +async def test_actor_create_update_delete(apify_client_async: ApifyClientAsync) -> None: + """Test creating, updating, and deleting an actor.""" + actor_name = get_random_resource_name('actor') + + # Create actor + created_actor = await apify_client_async.actors().create( + name=actor_name, + title='Test Actor', + description='Test actor for integration tests', + versions=[ + { + 'versionNumber': '0.1', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello")', + } + ], + } + ], + ) + assert created_actor is not None + assert created_actor.id is not None + assert created_actor.name == actor_name + + actor_client = apify_client_async.actor(created_actor.id) + + try: + # Update actor (only title and description - updating defaultRunOptions requires build to be set) + new_title = 'Updated Test Actor' + new_description = 'Updated description' + updated_actor = await actor_client.update( + title=new_title, + description=new_description, + ) + assert updated_actor is not None + assert updated_actor.title == new_title + assert updated_actor.description == new_description + + # Verify update persisted + retrieved_actor = await actor_client.get() + assert retrieved_actor is not None + assert retrieved_actor.title == new_title + + finally: + # Cleanup - delete actor + await actor_client.delete() + + # Verify deletion + deleted_actor = await actor_client.get() + assert deleted_actor is None + + +async def test_actor_default_build(apify_client_async: ApifyClientAsync) -> None: + """Test getting an actor's default build.""" + # Use a public actor that has builds + actor_client = apify_client_async.actor('apify/hello-world') + + # Get default build client + build_client = await actor_client.default_build() + assert build_client is not None + + # Use the returned client to get the build + build = await build_client.get() + assert build is not None + assert build.id is not None + assert build.status is not None + + +async def test_actor_last_run(apify_client_async: ApifyClientAsync) -> None: + """Test getting an actor's last run.""" + # First run an actor to ensure there is a last run + actor_client = apify_client_async.actor('apify/hello-world') + run = await actor_client.call() + assert run is not None + + try: + # Get last run client + last_run_client = actor_client.last_run() + assert last_run_client is not None + + # Use the returned client to get the run + last_run = await last_run_client.get() + assert last_run is not None + assert last_run.id is not None + + finally: + # Cleanup + await apify_client_async.run(run.id).delete() + + +async def test_actor_validate_input(apify_client_async: ApifyClientAsync) -> None: + """Test validating actor input.""" + # Use a public actor with an input schema + actor_client = apify_client_async.actor('apify/hello-world') + + # Valid input (hello-world accepts empty input or simple input) + is_valid = await actor_client.validate_input({}) + assert is_valid is True diff --git a/tests/integration/test_actor_env_var.py b/tests/integration/test_actor_env_var.py new file mode 100644 index 00000000..039137a2 --- /dev/null +++ b/tests/integration/test_actor_env_var.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .utils import get_random_resource_name + +if TYPE_CHECKING: + from apify_client import ApifyClient + + +def test_actor_env_var_list(apify_client: ApifyClient) -> None: + """Test listing actor version environment variables.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with a version that has env vars + actor = apify_client.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '0.0', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello")', + } + ], + 'envVars': [ + { + 'name': 'TEST_VAR', + 'value': 'test_value', + 'isSecret': False, + } + ], + } + ], + ) + actor_client = apify_client.actor(actor.id) + version_client = actor_client.version('0.0') + + try: + # List env vars + env_vars = version_client.env_vars().list() + + assert env_vars is not None + assert env_vars.items is not None + assert len(env_vars.items) >= 1 + + # Verify env var fields + env_var = env_vars.items[0] + assert env_var.name == 'TEST_VAR' + assert env_var.value == 'test_value' + + finally: + # Cleanup + actor_client.delete() + + +def test_actor_env_var_create_and_get(apify_client: ApifyClient) -> None: + """Test creating and getting an actor version environment variable.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with a version + actor = apify_client.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '1.0', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello")', + } + ], + } + ], + ) + actor_client = apify_client.actor(actor.id) + version_client = actor_client.version('1.0') + + try: + # Create a new env var + created_env_var = version_client.env_vars().create( + name='MY_VAR', + value='my_value', + is_secret=False, + ) + + assert created_env_var is not None + assert created_env_var.name == 'MY_VAR' + assert created_env_var.value == 'my_value' + assert created_env_var.is_secret is False + + # Get the same env var + env_var_client = version_client.env_var('MY_VAR') + retrieved_env_var = env_var_client.get() + + assert retrieved_env_var is not None + assert retrieved_env_var.name == 'MY_VAR' + assert retrieved_env_var.value == 'my_value' + + finally: + # Cleanup + actor_client.delete() + + +def test_actor_env_var_update(apify_client: ApifyClient) -> None: + """Test updating an actor version environment variable.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with a version and env var + actor = apify_client.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '0.1', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello")', + } + ], + 'envVars': [ + { + 'name': 'UPDATE_VAR', + 'value': 'initial_value', + 'isSecret': False, + } + ], + } + ], + ) + actor_client = apify_client.actor(actor.id) + version_client = actor_client.version('0.1') + env_var_client = version_client.env_var('UPDATE_VAR') + + try: + # Update the env var + updated_env_var = env_var_client.update( + name='UPDATE_VAR', + value='updated_value', + ) + + assert updated_env_var is not None + assert updated_env_var.name == 'UPDATE_VAR' + assert updated_env_var.value == 'updated_value' + + # Verify the update persisted + retrieved_env_var = env_var_client.get() + assert retrieved_env_var is not None + assert retrieved_env_var.value == 'updated_value' + + finally: + # Cleanup + actor_client.delete() + + +def test_actor_env_var_delete(apify_client: ApifyClient) -> None: + """Test deleting an actor version environment variable.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with a version and two env vars + actor = apify_client.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '0.1', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello")', + } + ], + 'envVars': [ + { + 'name': 'VAR_TO_DELETE', + 'value': 'delete_me', + 'isSecret': False, + }, + { + 'name': 'VAR_TO_KEEP', + 'value': 'keep_me', + 'isSecret': False, + }, + ], + } + ], + ) + actor_client = apify_client.actor(actor.id) + version_client = actor_client.version('0.1') + + try: + # Delete the first env var + env_var_client = version_client.env_var('VAR_TO_DELETE') + env_var_client.delete() + + # Verify it's gone + deleted_env_var = env_var_client.get() + assert deleted_env_var is None + + # Verify the other env var still exists + remaining_env_var = version_client.env_var('VAR_TO_KEEP').get() + assert remaining_env_var is not None + assert remaining_env_var.name == 'VAR_TO_KEEP' + + finally: + # Cleanup + actor_client.delete() diff --git a/tests/integration/test_actor_env_var_async.py b/tests/integration/test_actor_env_var_async.py new file mode 100644 index 00000000..1715bf09 --- /dev/null +++ b/tests/integration/test_actor_env_var_async.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .utils import get_random_resource_name + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + + +async def test_actor_env_var_list(apify_client_async: ApifyClientAsync) -> None: + """Test listing actor version environment variables.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with a version that has env vars + actor = await apify_client_async.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '0.0', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello")', + } + ], + 'envVars': [ + { + 'name': 'TEST_VAR', + 'value': 'test_value', + 'isSecret': False, + } + ], + } + ], + ) + actor_client = apify_client_async.actor(actor.id) + version_client = actor_client.version('0.0') + + try: + # List env vars + env_vars = await version_client.env_vars().list() + + assert env_vars is not None + assert env_vars.items is not None + assert len(env_vars.items) >= 1 + + # Verify env var fields + env_var = env_vars.items[0] + assert env_var.name == 'TEST_VAR' + assert env_var.value == 'test_value' + + finally: + # Cleanup + await actor_client.delete() + + +async def test_actor_env_var_create_and_get(apify_client_async: ApifyClientAsync) -> None: + """Test creating and getting an actor version environment variable.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with a version + actor = await apify_client_async.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '1.0', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello")', + } + ], + } + ], + ) + actor_client = apify_client_async.actor(actor.id) + version_client = actor_client.version('1.0') + + try: + # Create a new env var + created_env_var = await version_client.env_vars().create( + name='MY_VAR', + value='my_value', + is_secret=False, + ) + + assert created_env_var is not None + assert created_env_var.name == 'MY_VAR' + assert created_env_var.value == 'my_value' + assert created_env_var.is_secret is False + + # Get the same env var + env_var_client = version_client.env_var('MY_VAR') + retrieved_env_var = await env_var_client.get() + + assert retrieved_env_var is not None + assert retrieved_env_var.name == 'MY_VAR' + assert retrieved_env_var.value == 'my_value' + + finally: + # Cleanup + await actor_client.delete() + + +async def test_actor_env_var_update(apify_client_async: ApifyClientAsync) -> None: + """Test updating an actor version environment variable.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with a version and env var + actor = await apify_client_async.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '0.1', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello")', + } + ], + 'envVars': [ + { + 'name': 'UPDATE_VAR', + 'value': 'initial_value', + 'isSecret': False, + } + ], + } + ], + ) + actor_client = apify_client_async.actor(actor.id) + version_client = actor_client.version('0.1') + env_var_client = version_client.env_var('UPDATE_VAR') + + try: + # Update the env var + updated_env_var = await env_var_client.update( + name='UPDATE_VAR', + value='updated_value', + ) + + assert updated_env_var is not None + assert updated_env_var.name == 'UPDATE_VAR' + assert updated_env_var.value == 'updated_value' + + # Verify the update persisted + retrieved_env_var = await env_var_client.get() + assert retrieved_env_var is not None + assert retrieved_env_var.value == 'updated_value' + + finally: + # Cleanup + await actor_client.delete() + + +async def test_actor_env_var_delete(apify_client_async: ApifyClientAsync) -> None: + """Test deleting an actor version environment variable.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with a version and two env vars + actor = await apify_client_async.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '0.1', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello")', + } + ], + 'envVars': [ + { + 'name': 'VAR_TO_DELETE', + 'value': 'delete_me', + 'isSecret': False, + }, + { + 'name': 'VAR_TO_KEEP', + 'value': 'keep_me', + 'isSecret': False, + }, + ], + } + ], + ) + actor_client = apify_client_async.actor(actor.id) + version_client = actor_client.version('0.1') + + try: + # Delete the first env var + env_var_client = version_client.env_var('VAR_TO_DELETE') + await env_var_client.delete() + + # Verify it's gone + deleted_env_var = await env_var_client.get() + assert deleted_env_var is None + + # Verify the other env var still exists + remaining_env_var = await version_client.env_var('VAR_TO_KEEP').get() + assert remaining_env_var is not None + assert remaining_env_var.name == 'VAR_TO_KEEP' + + finally: + # Cleanup + await actor_client.delete() diff --git a/tests/integration/test_actor_version.py b/tests/integration/test_actor_version.py new file mode 100644 index 00000000..eede68ec --- /dev/null +++ b/tests/integration/test_actor_version.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .utils import get_random_resource_name +from apify_client._models import VersionSourceType + +if TYPE_CHECKING: + from apify_client import ApifyClient + + +def test_actor_version_list(apify_client: ApifyClient) -> None: + """Test listing actor versions.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with an initial version + actor = apify_client.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '0.0', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello")', + } + ], + } + ], + ) + actor_client = apify_client.actor(actor.id) + + try: + # List versions + versions = actor_client.versions().list() + + assert versions is not None + assert versions.items is not None + assert len(versions.items) >= 1 + + # Verify version fields + version = versions.items[0] + assert version.version_number == '0.0' + assert version.build_tag == 'latest' + + finally: + # Cleanup + actor_client.delete() + + +def test_actor_version_create_and_get(apify_client: ApifyClient) -> None: + """Test creating and getting an actor version.""" + actor_name = get_random_resource_name('actor') + + # Create an actor without versions + actor = apify_client.actors().create(name=actor_name) + actor_client = apify_client.actor(actor.id) + + try: + # Create a new version + created_version = actor_client.versions().create( + version_number='1.0', + source_type=VersionSourceType.SOURCE_FILES, + build_tag='test', + source_files=[ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello from version 1.0")', + } + ], + ) + + assert created_version is not None + assert created_version.version_number == '1.0' + assert created_version.build_tag == 'test' + assert created_version.source_type == VersionSourceType.SOURCE_FILES + + # Get the same version + version_client = actor_client.version('1.0') + retrieved_version = version_client.get() + + assert retrieved_version is not None + assert retrieved_version.version_number == '1.0' + assert retrieved_version.build_tag == 'test' + + finally: + # Cleanup + actor_client.delete() + + +def test_actor_version_update(apify_client: ApifyClient) -> None: + """Test updating an actor version.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with a version + actor = apify_client.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '0.1', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'initial', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Initial")', + } + ], + } + ], + ) + actor_client = apify_client.actor(actor.id) + version_client = actor_client.version('0.1') + + try: + # Update the version + updated_version = version_client.update( + build_tag='updated', + source_files=[ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Updated")', + } + ], + ) + + assert updated_version is not None + assert updated_version.version_number == '0.1' + assert updated_version.build_tag == 'updated' + + # Verify the update persisted + retrieved_version = version_client.get() + assert retrieved_version is not None + assert retrieved_version.build_tag == 'updated' + + finally: + # Cleanup + actor_client.delete() + + +def test_actor_version_delete(apify_client: ApifyClient) -> None: + """Test deleting an actor version.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with two versions + actor = apify_client.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '0.1', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'v1', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("v1")', + } + ], + }, + { + 'versionNumber': '0.2', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'v2', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("v2")', + } + ], + }, + ], + ) + actor_client = apify_client.actor(actor.id) + + try: + # Delete version 0.1 + version_client = actor_client.version('0.1') + version_client.delete() + + # Verify it's gone + deleted_version = version_client.get() + assert deleted_version is None + + # Verify version 0.2 still exists + remaining_version = actor_client.version('0.2').get() + assert remaining_version is not None + assert remaining_version.version_number == '0.2' + + finally: + # Cleanup + actor_client.delete() diff --git a/tests/integration/test_actor_version_async.py b/tests/integration/test_actor_version_async.py new file mode 100644 index 00000000..5be73676 --- /dev/null +++ b/tests/integration/test_actor_version_async.py @@ -0,0 +1,199 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .utils import get_random_resource_name +from apify_client._models import VersionSourceType + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + + +async def test_actor_version_list(apify_client_async: ApifyClientAsync) -> None: + """Test listing actor versions.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with an initial version + actor = await apify_client_async.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '0.0', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello")', + } + ], + } + ], + ) + actor_client = apify_client_async.actor(actor.id) + + try: + # List versions + versions = await actor_client.versions().list() + + assert versions is not None + assert versions.items is not None + assert len(versions.items) >= 1 + + # Verify version fields + version = versions.items[0] + assert version.version_number == '0.0' + assert version.build_tag == 'latest' + + finally: + # Cleanup + await actor_client.delete() + + +async def test_actor_version_create_and_get(apify_client_async: ApifyClientAsync) -> None: + """Test creating and getting an actor version.""" + actor_name = get_random_resource_name('actor') + + # Create an actor without versions + actor = await apify_client_async.actors().create(name=actor_name) + actor_client = apify_client_async.actor(actor.id) + + try: + # Create a new version + created_version = await actor_client.versions().create( + version_number='1.0', + source_type=VersionSourceType.SOURCE_FILES, + build_tag='test', + source_files=[ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello from version 1.0")', + } + ], + ) + + assert created_version is not None + assert created_version.version_number == '1.0' + assert created_version.build_tag == 'test' + assert created_version.source_type == VersionSourceType.SOURCE_FILES + + # Get the same version + version_client = actor_client.version('1.0') + retrieved_version = await version_client.get() + + assert retrieved_version is not None + assert retrieved_version.version_number == '1.0' + assert retrieved_version.build_tag == 'test' + + finally: + # Cleanup + await actor_client.delete() + + +async def test_actor_version_update(apify_client_async: ApifyClientAsync) -> None: + """Test updating an actor version.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with a version + actor = await apify_client_async.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '0.1', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'initial', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Initial")', + } + ], + } + ], + ) + actor_client = apify_client_async.actor(actor.id) + version_client = actor_client.version('0.1') + + try: + # Update the version + updated_version = await version_client.update( + build_tag='updated', + source_files=[ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Updated")', + } + ], + ) + + assert updated_version is not None + assert updated_version.version_number == '0.1' + assert updated_version.build_tag == 'updated' + + # Verify the update persisted + retrieved_version = await version_client.get() + assert retrieved_version is not None + assert retrieved_version.build_tag == 'updated' + + finally: + # Cleanup + await actor_client.delete() + + +async def test_actor_version_delete(apify_client_async: ApifyClientAsync) -> None: + """Test deleting an actor version.""" + actor_name = get_random_resource_name('actor') + + # Create an actor with two versions + actor = await apify_client_async.actors().create( + name=actor_name, + versions=[ + { + 'versionNumber': '0.1', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'v1', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("v1")', + } + ], + }, + { + 'versionNumber': '0.2', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'v2', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("v2")', + } + ], + }, + ], + ) + actor_client = apify_client_async.actor(actor.id) + + try: + # Delete version 0.1 + version_client = actor_client.version('0.1') + await version_client.delete() + + # Verify it's gone + deleted_version = await version_client.get() + assert deleted_version is None + + # Verify version 0.2 still exists + remaining_version = await actor_client.version('0.2').get() + assert remaining_version is not None + assert remaining_version.version_number == '0.2' + + finally: + # Cleanup + await actor_client.delete() diff --git a/tests/integration/test_apify_client.py b/tests/integration/test_apify_client.py new file mode 100644 index 00000000..50679e83 --- /dev/null +++ b/tests/integration/test_apify_client.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from apify_client._models import UserPrivateInfo, UserPublicInfo + +if TYPE_CHECKING: + from apify_client import ApifyClient + + +def test_apify_client(apify_client: ApifyClient) -> None: + user_client = apify_client.user('me') + me = user_client.get() + assert isinstance(me, (UserPrivateInfo, UserPublicInfo)) + assert me.username is not None diff --git a/tests/integration/test_apify_client_async.py b/tests/integration/test_apify_client_async.py new file mode 100644 index 00000000..ebeba845 --- /dev/null +++ b/tests/integration/test_apify_client_async.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from apify_client._models import UserPrivateInfo, UserPublicInfo + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + + +async def test_apify_client(apify_client_async: ApifyClientAsync) -> None: + user_client = apify_client_async.user('me') + me = await user_client.get() + assert isinstance(me, (UserPrivateInfo, UserPublicInfo)) + assert me.username is not None diff --git a/tests/integration/test_basic.py b/tests/integration/test_basic.py deleted file mode 100644 index b8eec5f4..00000000 --- a/tests/integration/test_basic.py +++ /dev/null @@ -1,22 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from apify_client import ApifyClient, ApifyClientAsync - - -class TestBasicSync: - def test_basic(self, apify_client: ApifyClient) -> None: - me = apify_client.user('me').get() - assert me is not None - assert me.get('id') is not None - assert me.get('username') is not None - - -class TestBasicAsync: - async def test_basic(self, apify_client_async: ApifyClientAsync) -> None: - me = await apify_client_async.user('me').get() - assert me is not None - assert me.get('id') is not None - assert me.get('username') is not None diff --git a/tests/integration/test_build.py b/tests/integration/test_build.py new file mode 100644 index 00000000..27a9c777 --- /dev/null +++ b/tests/integration/test_build.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .utils import get_random_resource_name + +if TYPE_CHECKING: + from apify_client import ApifyClient + +# Use a public actor that has builds available +HELLO_WORLD_ACTOR = 'apify/hello-world' + + +def test_build_list_for_actor(apify_client: ApifyClient) -> None: + """Test listing builds for a public actor.""" + # Get builds for hello-world actor + actor = apify_client.actor(HELLO_WORLD_ACTOR) + builds_page = actor.builds().list(limit=10) + + assert builds_page is not None + assert builds_page.items is not None + assert len(builds_page.items) > 0 # hello-world should have at least one build + + # Verify build structure + first_build = builds_page.items[0] + assert first_build.id is not None + assert first_build.act_id is not None + + +def test_build_get(apify_client: ApifyClient) -> None: + """Test getting a specific build.""" + # First list builds to get a build ID + actor = apify_client.actor(HELLO_WORLD_ACTOR) + builds_page = actor.builds().list(limit=1) + assert builds_page.items + build_id = builds_page.items[0].id + + # Get the specific build + build = apify_client.build(build_id).get() + + assert build is not None + assert build.id == build_id + assert build.act_id is not None + assert build.status is not None + + +def test_user_builds_list(apify_client: ApifyClient) -> None: + """Test listing all user builds.""" + # List user's builds (may be empty if user has no actors) + builds_page = apify_client.builds().list(limit=10) + + assert builds_page is not None + assert builds_page.items is not None + # User may have 0 builds, so we just check the structure + assert isinstance(builds_page.items, list) + + +def test_build_log(apify_client: ApifyClient) -> None: + """Test getting build log.""" + # First list builds to get a completed build ID + actor = apify_client.actor(HELLO_WORLD_ACTOR) + builds_page = actor.builds().list(limit=5) + assert builds_page.items + + # Find a completed build (SUCCEEDED status) + completed_build = None + for build in builds_page.items: + if build.status and build.status.value == 'SUCCEEDED': + completed_build = build + break + + if completed_build is None: + # If no succeeded build found, use any build + completed_build = builds_page.items[0] + + # Get the build log + log_client = apify_client.build(completed_build.id).log() + log_content = log_client.get() + + # Build logs should be available for completed builds + assert log_content is not None + + +def test_build_wait_for_finish(apify_client: ApifyClient) -> None: + """Test wait_for_finish on an already completed build.""" + # First list builds to get a completed build ID + actor = apify_client.actor(HELLO_WORLD_ACTOR) + builds_page = actor.builds().list(limit=5) + assert builds_page.items + + # Find a completed build (SUCCEEDED status) + completed_build = None + for build in builds_page.items: + if build.status and build.status.value == 'SUCCEEDED': + completed_build = build + break + + if completed_build is None: + # If no succeeded build found, use any finished build + for build in builds_page.items: + if build.status and build.status.value in ('SUCCEEDED', 'FAILED', 'ABORTED', 'TIMED_OUT'): + completed_build = build + break + + if completed_build is None: + completed_build = builds_page.items[0] + + # Wait for finish on already completed build (should return immediately) + build = apify_client.build(completed_build.id).wait_for_finish(wait_secs=5) + + assert build is not None + assert build.id == completed_build.id + + +def test_build_delete_and_abort(apify_client: ApifyClient) -> None: + """Test deleting and aborting a build on our own actor.""" + actor_name = get_random_resource_name('actor') + + # Create actor with two versions + created_actor = apify_client.actors().create( + name=actor_name, + title='Test Actor for Build Delete', + versions=[ + { + 'versionNumber': '0.1', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'beta', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello v0.1")', + } + ], + }, + { + 'versionNumber': '0.2', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello v0.2")', + } + ], + }, + ], + ) + assert created_actor is not None + actor_client = apify_client.actor(created_actor.id) + + try: + # Build both versions - we need 2 builds because we can't delete the default build + first_build = actor_client.build(version_number='0.1') + assert first_build is not None + first_build_client = apify_client.build(first_build.id) + first_build_client.wait_for_finish() + + second_build = actor_client.build(version_number='0.2') + assert second_build is not None + second_build_client = apify_client.build(second_build.id) + + # Wait for the second build to finish + finished_build = second_build_client.wait_for_finish() + assert finished_build is not None + assert finished_build.status.value in ('SUCCEEDED', 'FAILED') + + # Test abort on already finished build (should return the build in its current state) + aborted_build = second_build_client.abort() + assert aborted_build is not None + assert aborted_build.status.value in ('SUCCEEDED', 'FAILED', 'ABORTED') + + # Delete the first build (not the default/latest) + first_build_client.delete() + + # Verify the build is deleted + deleted_build = first_build_client.get() + assert deleted_build is None + + finally: + # Cleanup - delete actor + actor_client.delete() + + +def test_build_get_open_api_definition(apify_client: ApifyClient) -> None: + """Test getting OpenAPI definition for a build.""" + # Get builds for hello-world actor + actor = apify_client.actor(HELLO_WORLD_ACTOR) + builds_page = actor.builds().list(limit=1) + assert builds_page.items + build_id = builds_page.items[0].id + + # Get the OpenAPI definition + build_client = apify_client.build(build_id) + openapi_def = build_client.get_open_api_definition() + + # OpenAPI definition should be a dict with standard OpenAPI fields + # Note: May be None if the actor doesn't have an OpenAPI definition + if openapi_def is not None: + assert isinstance(openapi_def, dict) diff --git a/tests/integration/test_build_async.py b/tests/integration/test_build_async.py new file mode 100644 index 00000000..374c6fc3 --- /dev/null +++ b/tests/integration/test_build_async.py @@ -0,0 +1,201 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .utils import get_random_resource_name + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + +# Use a public actor that has builds available +HELLO_WORLD_ACTOR = 'apify/hello-world' + + +async def test_build_list_for_actor(apify_client_async: ApifyClientAsync) -> None: + """Test listing builds for a public actor.""" + # Get builds for hello-world actor + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + builds_page = await actor.builds().list(limit=10) + + assert builds_page is not None + assert builds_page.items is not None + assert len(builds_page.items) > 0 # hello-world should have at least one build + + # Verify build structure + first_build = builds_page.items[0] + assert first_build.id is not None + assert first_build.act_id is not None + + +async def test_build_get(apify_client_async: ApifyClientAsync) -> None: + """Test getting a specific build.""" + # First list builds to get a build ID + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + builds_page = await actor.builds().list(limit=1) + assert builds_page.items + build_id = builds_page.items[0].id + + # Get the specific build + build = await apify_client_async.build(build_id).get() + + assert build is not None + assert build.id == build_id + assert build.act_id is not None + assert build.status is not None + + +async def test_user_builds_list(apify_client_async: ApifyClientAsync) -> None: + """Test listing all user builds.""" + # List user's builds (may be empty if user has no actors) + builds_page = await apify_client_async.builds().list(limit=10) + + assert builds_page is not None + assert builds_page.items is not None + # User may have 0 builds, so we just check the structure + assert isinstance(builds_page.items, list) + + +async def test_build_log(apify_client_async: ApifyClientAsync) -> None: + """Test getting build log.""" + # First list builds to get a completed build ID + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + builds_page = await actor.builds().list(limit=5) + assert builds_page.items + + # Find a completed build (SUCCEEDED status) + completed_build = None + for build in builds_page.items: + if build.status and build.status.value == 'SUCCEEDED': + completed_build = build + break + + if completed_build is None: + # If no succeeded build found, use any build + completed_build = builds_page.items[0] + + # Get the build log + log_client = apify_client_async.build(completed_build.id).log() + log_content = await log_client.get() + + # Build logs should be available for completed builds + assert log_content is not None + + +async def test_build_wait_for_finish(apify_client_async: ApifyClientAsync) -> None: + """Test wait_for_finish on an already completed build.""" + # First list builds to get a completed build ID + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + builds_page = await actor.builds().list(limit=5) + assert builds_page.items + + # Find a completed build (SUCCEEDED status) + completed_build = None + for build in builds_page.items: + if build.status and build.status.value == 'SUCCEEDED': + completed_build = build + break + + if completed_build is None: + # If no succeeded build found, use any finished build + for build in builds_page.items: + if build.status and build.status.value in ('SUCCEEDED', 'FAILED', 'ABORTED', 'TIMED_OUT'): + completed_build = build + break + + if completed_build is None: + completed_build = builds_page.items[0] + + # Wait for finish on already completed build (should return immediately) + build = await apify_client_async.build(completed_build.id).wait_for_finish(wait_secs=5) + + assert build is not None + assert build.id == completed_build.id + + +async def test_build_delete_and_abort(apify_client_async: ApifyClientAsync) -> None: + """Test deleting and aborting a build on our own actor.""" + actor_name = get_random_resource_name('actor') + + # Create actor with two versions + created_actor = await apify_client_async.actors().create( + name=actor_name, + title='Test Actor for Build Delete', + versions=[ + { + 'versionNumber': '0.1', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'beta', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello v0.1")', + } + ], + }, + { + 'versionNumber': '0.2', + 'sourceType': 'SOURCE_FILES', + 'buildTag': 'latest', + 'sourceFiles': [ + { + 'name': 'main.js', + 'format': 'TEXT', + 'content': 'console.log("Hello v0.2")', + } + ], + }, + ], + ) + assert created_actor is not None + actor_client = apify_client_async.actor(created_actor.id) + + try: + # Build both versions - we need 2 builds because we can't delete the default build + first_build = await actor_client.build(version_number='0.1') + assert first_build is not None + first_build_client = apify_client_async.build(first_build.id) + await first_build_client.wait_for_finish() + + second_build = await actor_client.build(version_number='0.2') + assert second_build is not None + second_build_client = apify_client_async.build(second_build.id) + + # Wait for the second build to finish + finished_build = await second_build_client.wait_for_finish() + assert finished_build is not None + assert finished_build.status.value in ('SUCCEEDED', 'FAILED') + + # Test abort on already finished build (should return the build in its current state) + aborted_build = await second_build_client.abort() + assert aborted_build is not None + assert aborted_build.status.value in ('SUCCEEDED', 'FAILED', 'ABORTED') + + # Delete the first build (not the default/latest) + await first_build_client.delete() + + # Verify the build is deleted + deleted_build = await first_build_client.get() + assert deleted_build is None + + finally: + # Cleanup - delete actor + await actor_client.delete() + + +async def test_build_get_open_api_definition(apify_client_async: ApifyClientAsync) -> None: + """Test getting OpenAPI definition for a build.""" + # Get builds for hello-world actor + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + builds_page = await actor.builds().list(limit=1) + assert builds_page.items + build_id = builds_page.items[0].id + + # Get the OpenAPI definition + build_client = apify_client_async.build(build_id) + openapi_def = await build_client.get_open_api_definition() + + # OpenAPI definition should be a dict with standard OpenAPI fields + # Note: May be None if the actor doesn't have an OpenAPI definition + if openapi_def is not None: + assert isinstance(openapi_def, dict) diff --git a/tests/integration/test_dataset.py b/tests/integration/test_dataset.py index cb33f426..07fc15f5 100644 --- a/tests/integration/test_dataset.py +++ b/tests/integration/test_dataset.py @@ -1,18 +1,22 @@ from __future__ import annotations import json +import time from unittest import mock from unittest.mock import Mock import impit import pytest -from integration.integration_test_utils import TestDataset, parametrized_api_urls, random_resource_name - -from apify_client import ApifyClient, ApifyClientAsync -from apify_client.client import DEFAULT_API_URL +from .utils import DatasetFixture, get_random_resource_name, parametrized_api_urls +from apify_client import ApifyClient +from apify_client._config import DEFAULT_API_URL from apify_client.errors import ApifyApiError +################################################## +# OLD TESTS - Tests with mocks and signed URLs +################################################## + MOCKED_API_DATASET_RESPONSE = """{ "data": { "id": "someID", @@ -21,6 +25,8 @@ "createdAt": "2025-09-11T08:48:51.806Z", "modifiedAt": "2025-09-11T08:48:51.806Z", "accessedAt": "2025-09-11T08:48:51.806Z", + "itemCount": 0, + "cleanItemCount": 0, "actId": null, "actRunId": null, "schema": null, @@ -40,217 +46,358 @@ }""" -class TestDatasetSync: - def test_dataset_should_create_public_items_expiring_url_with_params(self, apify_client: ApifyClient) -> None: - created_dataset = apify_client.datasets().get_or_create(name=random_resource_name('dataset')) +def test_dataset_should_create_public_items_expiring_url_with_params(apify_client: ApifyClient) -> None: + created_dataset = apify_client.datasets().get_or_create(name=get_random_resource_name('dataset')) - dataset = apify_client.dataset(created_dataset['id']) - items_public_url = dataset.create_items_public_url( - expires_in_secs=2000, - limit=10, - offset=0, - ) + dataset = apify_client.dataset(created_dataset.id) + items_public_url = dataset.create_items_public_url( + expires_in_secs=2000, + limit=10, + offset=0, + ) - assert 'signature=' in items_public_url - assert 'limit=10' in items_public_url - assert 'offset=0' in items_public_url - - impit_client = impit.Client() - response = impit_client.get(items_public_url, timeout=5) - assert response.status_code == 200 - - dataset.delete() - assert apify_client.dataset(created_dataset['id']).get() is None - - def test_dataset_should_create_public_items_non_expiring_url(self, apify_client: ApifyClient) -> None: - created_dataset = apify_client.datasets().get_or_create(name=random_resource_name('dataset')) - - dataset = apify_client.dataset(created_dataset['id']) - items_public_url = dataset.create_items_public_url() - - assert 'signature=' in items_public_url - - impit_client = impit.Client() - response = impit_client.get(items_public_url, timeout=5) - assert response.status_code == 200 - - dataset.delete() - assert apify_client.dataset(created_dataset['id']).get() is None - - @parametrized_api_urls - def test_public_url(self, api_token: str, api_url: str, api_public_url: str) -> None: - apify_client = ApifyClient(token=api_token, api_url=api_url, api_public_url=api_public_url) - dataset = apify_client.dataset('someID') - - # Mock the API call to return predefined response - mock_response = Mock() - mock_response.json.return_value = json.loads(MOCKED_API_DATASET_RESPONSE) - with mock.patch.object(apify_client.http_client, 'call', return_value=mock_response): - public_url = dataset.create_items_public_url() - assert public_url == ( - f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/datasets/' - f'someID/items?signature={public_url.split("signature=")[1]}' - ) - - def test_list_items_signature(self, apify_client: ApifyClient, test_dataset_of_another_user: TestDataset) -> None: - dataset = apify_client.dataset(dataset_id=test_dataset_of_another_user.id) - - # Permission error without valid signature - with pytest.raises( - ApifyApiError, - match=r"Insufficient permissions for the dataset. Make sure you're passing a " - r'correct API token and that it has the required permissions.', - ): - dataset.list_items() - - # Dataset content retrieved with correct signature - assert ( - test_dataset_of_another_user.expected_content - == dataset.list_items(signature=test_dataset_of_another_user.signature).items - ) + assert 'signature=' in items_public_url + assert 'limit=10' in items_public_url + assert 'offset=0' in items_public_url - def test_iterate_items_signature( - self, apify_client: ApifyClient, test_dataset_of_another_user: TestDataset - ) -> None: - dataset = apify_client.dataset(dataset_id=test_dataset_of_another_user.id) - - # Permission error without valid signature - with pytest.raises( - ApifyApiError, - match=r"Insufficient permissions for the dataset. Make sure you're passing a " - r'correct API token and that it has the required permissions.', - ): - list(dataset.iterate_items()) - - # Dataset content retrieved with correct signature - assert test_dataset_of_another_user.expected_content == list( - dataset.iterate_items(signature=test_dataset_of_another_user.signature) - ) + impit_client = impit.Client() + response = impit_client.get(items_public_url, timeout=5) + assert response.status_code == 200 - def test_get_items_as_bytes_signature( - self, apify_client: ApifyClient, test_dataset_of_another_user: TestDataset - ) -> None: - dataset = apify_client.dataset(dataset_id=test_dataset_of_another_user.id) - - # Permission error without valid signature - with pytest.raises( - ApifyApiError, - match=r"Insufficient permissions for the dataset. Make sure you're passing a " - r'correct API token and that it has the required permissions.', - ): - dataset.get_items_as_bytes() - - # Dataset content retrieved with correct signature - raw_data = dataset.get_items_as_bytes(signature=test_dataset_of_another_user.signature) - assert test_dataset_of_another_user.expected_content == json.loads(raw_data.decode('utf-8')) - - -class TestDatasetAsync: - async def test_dataset_should_create_public_items_expiring_url_with_params( - self, apify_client_async: ApifyClientAsync - ) -> None: - created_dataset = await apify_client_async.datasets().get_or_create(name=random_resource_name('dataset')) - - dataset = apify_client_async.dataset(created_dataset['id']) - items_public_url = await dataset.create_items_public_url( - expires_in_secs=2000, - limit=10, - offset=0, - ) + dataset.delete() + assert apify_client.dataset(created_dataset.id).get() is None + + +def test_dataset_should_create_public_items_non_expiring_url(apify_client: ApifyClient) -> None: + created_dataset = apify_client.datasets().get_or_create(name=get_random_resource_name('dataset')) + + dataset = apify_client.dataset(created_dataset.id) + items_public_url = dataset.create_items_public_url() + + assert 'signature=' in items_public_url + + impit_client = impit.Client() + response = impit_client.get(items_public_url, timeout=5) + assert response.status_code == 200 - assert 'signature=' in items_public_url - assert 'limit=10' in items_public_url - assert 'offset=0' in items_public_url - - impit_async_client = impit.AsyncClient() - response = await impit_async_client.get(items_public_url, timeout=5) - assert response.status_code == 200 - - await dataset.delete() - assert await apify_client_async.dataset(created_dataset['id']).get() is None - - async def test_dataset_should_create_public_items_non_expiring_url( - self, apify_client_async: ApifyClientAsync - ) -> None: - created_dataset = await apify_client_async.datasets().get_or_create(name=random_resource_name('dataset')) - - dataset = apify_client_async.dataset(created_dataset['id']) - items_public_url = await dataset.create_items_public_url() - - assert 'signature=' in items_public_url - - impit_async_client = impit.AsyncClient() - response = await impit_async_client.get(items_public_url, timeout=5) - assert response.status_code == 200 - - await dataset.delete() - assert await apify_client_async.dataset(created_dataset['id']).get() is None - - @parametrized_api_urls - async def test_public_url(self, api_token: str, api_url: str, api_public_url: str) -> None: - apify_client = ApifyClientAsync(token=api_token, api_url=api_url, api_public_url=api_public_url) - dataset = apify_client.dataset('someID') - - # Mock the API call to return predefined response - mock_response = Mock() - mock_response.json.return_value = json.loads(MOCKED_API_DATASET_RESPONSE) - with mock.patch.object(apify_client.http_client, 'call', return_value=mock_response): - public_url = await dataset.create_items_public_url() - assert public_url == ( - f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/datasets/' - f'someID/items?signature={public_url.split("signature=")[1]}' - ) - - async def test_list_items_signature( - self, apify_client_async: ApifyClientAsync, test_dataset_of_another_user: TestDataset - ) -> None: - dataset = apify_client_async.dataset(dataset_id=test_dataset_of_another_user.id) - - # Permission error without valid signature - with pytest.raises( - ApifyApiError, - match=r"Insufficient permissions for the dataset. Make sure you're passing a " - r'correct API token and that it has the required permissions.', - ): - await dataset.list_items() - - # Dataset content retrieved with correct signature - assert ( - test_dataset_of_another_user.expected_content - == (await dataset.list_items(signature=test_dataset_of_another_user.signature)).items + dataset.delete() + assert apify_client.dataset(created_dataset.id).get() is None + + +@parametrized_api_urls +def test_public_url(api_token: str, api_url: str, api_public_url: str) -> None: + apify_client = ApifyClient(token=api_token, api_url=api_url, api_public_url=api_public_url) + dataset = apify_client.dataset('someID') + + # Mock the API call to return predefined response + mock_response = Mock() + mock_response.json.return_value = json.loads(MOCKED_API_DATASET_RESPONSE) + with mock.patch.object(apify_client._http_client, 'call', return_value=mock_response): + public_url = dataset.create_items_public_url() + assert public_url == ( + f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/datasets/' + f'someID/items?signature={public_url.split("signature=")[1]}' ) - async def test_iterate_items_signature( - self, apify_client_async: ApifyClientAsync, test_dataset_of_another_user: TestDataset - ) -> None: - dataset = apify_client_async.dataset(dataset_id=test_dataset_of_another_user.id) - - # Permission error without valid signature - with pytest.raises( - ApifyApiError, - match=r"Insufficient permissions for the dataset. Make sure you're passing a " - r'correct API token and that it has the required permissions.', - ): - [item async for item in dataset.iterate_items()] - - # Dataset content retrieved with correct signature - assert test_dataset_of_another_user.expected_content == [ - item async for item in dataset.iterate_items(signature=test_dataset_of_another_user.signature) + +def test_list_items_signature(apify_client: ApifyClient, test_dataset_of_another_user: DatasetFixture) -> None: + dataset = apify_client.dataset(dataset_id=test_dataset_of_another_user.id) + + # Permission error without valid signature + with pytest.raises( + ApifyApiError, + match=r"Insufficient permissions for the dataset. Make sure you're passing a " + r'correct API token and that it has the required permissions.', + ): + dataset.list_items() + + # Dataset content retrieved with correct signature + assert ( + test_dataset_of_another_user.expected_content + == dataset.list_items(signature=test_dataset_of_another_user.signature).items + ) + + +def test_iterate_items_signature(apify_client: ApifyClient, test_dataset_of_another_user: DatasetFixture) -> None: + dataset = apify_client.dataset(dataset_id=test_dataset_of_another_user.id) + + # Permission error without valid signature + with pytest.raises( + ApifyApiError, + match=r"Insufficient permissions for the dataset. Make sure you're passing a " + r'correct API token and that it has the required permissions.', + ): + list(dataset.iterate_items()) + + # Dataset content retrieved with correct signature + assert test_dataset_of_another_user.expected_content == list( + dataset.iterate_items(signature=test_dataset_of_another_user.signature) + ) + + +def test_get_items_as_bytes_signature(apify_client: ApifyClient, test_dataset_of_another_user: DatasetFixture) -> None: + dataset = apify_client.dataset(dataset_id=test_dataset_of_another_user.id) + + # Permission error without valid signature + with pytest.raises( + ApifyApiError, + match=r"Insufficient permissions for the dataset. Make sure you're passing a " + r'correct API token and that it has the required permissions.', + ): + dataset.get_items_as_bytes() + + # Dataset content retrieved with correct signature + raw_data = dataset.get_items_as_bytes(signature=test_dataset_of_another_user.signature) + assert test_dataset_of_another_user.expected_content == json.loads(raw_data.decode('utf-8')) + + +############# +# NEW TESTS # +############# + + +def test_dataset_get_or_create_and_get(apify_client: ApifyClient) -> None: + """Test creating a dataset and retrieving it.""" + dataset_name = get_random_resource_name('dataset') + + # Create dataset + created_dataset = apify_client.datasets().get_or_create(name=dataset_name) + assert created_dataset is not None + assert created_dataset.id is not None + assert created_dataset.name == dataset_name + + # Get the same dataset + dataset_client = apify_client.dataset(created_dataset.id) + retrieved_dataset = dataset_client.get() + assert retrieved_dataset is not None + assert retrieved_dataset.id == created_dataset.id + assert retrieved_dataset.name == dataset_name + + # Cleanup + dataset_client.delete() + + +def test_dataset_update(apify_client: ApifyClient) -> None: + """Test updating dataset properties.""" + dataset_name = get_random_resource_name('dataset') + new_name = get_random_resource_name('dataset-updated') + + created_dataset = apify_client.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client.dataset(created_dataset.id) + + # Update the name + updated_dataset = dataset_client.update(name=new_name) + assert updated_dataset is not None + assert updated_dataset.name == new_name + assert updated_dataset.id == created_dataset.id + + # Verify the update persisted + retrieved_dataset = dataset_client.get() + assert retrieved_dataset is not None + assert retrieved_dataset.name == new_name + + # Cleanup + dataset_client.delete() + + +def test_dataset_push_and_list_items(apify_client: ApifyClient) -> None: + """Test pushing items to dataset and listing them.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = apify_client.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client.dataset(created_dataset.id) + + # Push some items + items_to_push = [ + {'id': 1, 'name': 'Item 1', 'value': 100}, + {'id': 2, 'name': 'Item 2', 'value': 200}, + {'id': 3, 'name': 'Item 3', 'value': 300}, + ] + dataset_client.push_items(items_to_push) + + # Wait briefly for eventual consistency + time.sleep(1) + + # List items + items_page = dataset_client.list_items() + assert items_page is not None + assert len(items_page.items) == 3 + assert items_page.count == 3 + # Note: items_page.total may be 0 immediately after push due to eventual consistency + + # Verify items content + for i, item in enumerate(items_page.items): + assert item['id'] == items_to_push[i]['id'] + assert item['name'] == items_to_push[i]['name'] + assert item['value'] == items_to_push[i]['value'] + + # Cleanup + dataset_client.delete() + + +def test_dataset_list_items_with_pagination(apify_client: ApifyClient) -> None: + """Test listing items with pagination parameters.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = apify_client.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client.dataset(created_dataset.id) + + # Push more items + items_to_push = [{'index': i, 'value': i * 10} for i in range(10)] + dataset_client.push_items(items_to_push) + + # Wait briefly for eventual consistency + time.sleep(1) + + # List with limit + items_page = dataset_client.list_items(limit=5) + assert len(items_page.items) == 5 + assert items_page.count == 5 + # Note: items_page.total may be 0 immediately after push due to eventual consistency + assert items_page.limit == 5 + + # List with offset + items_page_offset = dataset_client.list_items(offset=5, limit=5) + assert len(items_page_offset.items) == 5 + assert items_page_offset.offset == 5 + # Note: items_page.total may be 0 immediately after push due to eventual consistency + + # Verify different items + assert items_page.items[0]['index'] != items_page_offset.items[0]['index'] + + # Cleanup + dataset_client.delete() + + +def test_dataset_list_items_with_fields(apify_client: ApifyClient) -> None: + """Test listing items with field filtering.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = apify_client.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client.dataset(created_dataset.id) + + # Push items with multiple fields + items_to_push = [ + {'id': 1, 'name': 'Item 1', 'value': 100, 'extra': 'data1'}, + {'id': 2, 'name': 'Item 2', 'value': 200, 'extra': 'data2'}, + ] + dataset_client.push_items(items_to_push) + + # Wait briefly for eventual consistency + time.sleep(1) + + # List with fields filter + items_page = dataset_client.list_items(fields=['id', 'name']) + assert len(items_page.items) == 2 + + # Verify only specified fields are returned + for item in items_page.items: + assert 'id' in item + assert 'name' in item + assert 'value' not in item + assert 'extra' not in item + + # Cleanup + dataset_client.delete() + + +def test_dataset_iterate_items(apify_client: ApifyClient) -> None: + """Test iterating over dataset items.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = apify_client.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client.dataset(created_dataset.id) + + # Push items + items_to_push = [{'index': i} for i in range(5)] + dataset_client.push_items(items_to_push) + + # Wait briefly for eventual consistency + time.sleep(1) + + # Iterate over items + collected_items = list(dataset_client.iterate_items()) + + assert len(collected_items) == 5 + for i, item in enumerate(collected_items): + assert item['index'] == i + + # Cleanup + dataset_client.delete() + + +def test_dataset_delete_nonexistent(apify_client: ApifyClient) -> None: + """Test that getting a deleted dataset returns None.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = apify_client.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client.dataset(created_dataset.id) + + # Delete dataset + dataset_client.delete() + + # Verify it's gone + retrieved_dataset = dataset_client.get() + assert retrieved_dataset is None + + +def test_dataset_get_statistics(apify_client: ApifyClient) -> None: + """Test getting dataset statistics.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = apify_client.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client.dataset(created_dataset.id) + + try: + # Push some items first + items_to_push = [ + {'id': 1, 'name': 'Item 1'}, + {'id': 2, 'name': 'Item 2'}, ] + dataset_client.push_items(items_to_push) + + # Wait briefly for eventual consistency + time.sleep(1) + + # Get statistics + statistics = dataset_client.get_statistics() + + # Verify statistics is returned and properly parsed + assert statistics is not None - async def test_get_items_as_bytes_signature( - self, apify_client_async: ApifyClientAsync, test_dataset_of_another_user: TestDataset - ) -> None: - dataset = apify_client_async.dataset(dataset_id=test_dataset_of_another_user.id) - - # Permission error without valid signature - with pytest.raises( - ApifyApiError, - match=r"Insufficient permissions for the dataset. Make sure you're passing a " - r'correct API token and that it has the required permissions.', - ): - await dataset.get_items_as_bytes() - - # Dataset content retrieved with correct signature - raw_data = await dataset.get_items_as_bytes(signature=test_dataset_of_another_user.signature) - assert test_dataset_of_another_user.expected_content == json.loads(raw_data.decode('utf-8')) + finally: + # Cleanup + dataset_client.delete() + + +def test_dataset_stream_items(apify_client: ApifyClient) -> None: + """Test streaming dataset items.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = apify_client.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client.dataset(created_dataset.id) + + try: + # Push some items + items_to_push = [ + {'id': 1, 'name': 'Item 1', 'value': 100}, + {'id': 2, 'name': 'Item 2', 'value': 200}, + {'id': 3, 'name': 'Item 3', 'value': 300}, + ] + dataset_client.push_items(items_to_push) + + # Wait briefly for eventual consistency + time.sleep(1) + + # Stream items using context manager + with dataset_client.stream_items(item_format='json') as response: + assert response is not None + assert response.status_code == 200 + content = response.read() + items = json.loads(content) + assert len(items) == 3 + assert items[0]['id'] == 1 + + finally: + # Cleanup + dataset_client.delete() diff --git a/tests/integration/test_dataset_async.py b/tests/integration/test_dataset_async.py new file mode 100644 index 00000000..dea1e028 --- /dev/null +++ b/tests/integration/test_dataset_async.py @@ -0,0 +1,411 @@ +from __future__ import annotations + +import asyncio +import json +from unittest import mock +from unittest.mock import Mock + +import impit +import pytest + +from .utils import DatasetFixture, get_random_resource_name, parametrized_api_urls +from apify_client import ApifyClientAsync +from apify_client._config import DEFAULT_API_URL +from apify_client.errors import ApifyApiError + +################################################## +# OLD TESTS - Tests with mocks and signed URLs +################################################## + +MOCKED_API_DATASET_RESPONSE = """{ + "data": { + "id": "someID", + "name": "name", + "userId": "userId", + "createdAt": "2025-09-11T08:48:51.806Z", + "modifiedAt": "2025-09-11T08:48:51.806Z", + "accessedAt": "2025-09-11T08:48:51.806Z", + "itemCount": 0, + "cleanItemCount": 0, + "actId": null, + "actRunId": null, + "schema": null, + "stats": { + "readCount": 0, + "writeCount": 0, + "deleteCount": 0, + "listCount": 0, + "storageBytes": 0 + }, + "fields": [], + "consoleUrl": "https://console.apify.com/storage/datasets/someID", + "itemsPublicUrl": "https://api.apify.com/v2/datasets/someID/items", + "generalAccess": "FOLLOW_USER_SETTING", + "urlSigningSecretKey": "urlSigningSecretKey" + } +}""" + + +async def test_dataset_should_create_public_items_expiring_url_with_params( + apify_client_async: ApifyClientAsync, +) -> None: + created_dataset = await apify_client_async.datasets().get_or_create(name=get_random_resource_name('dataset')) + + dataset = apify_client_async.dataset(created_dataset.id) + items_public_url = await dataset.create_items_public_url( + expires_in_secs=2000, + limit=10, + offset=0, + ) + + assert 'signature=' in items_public_url + assert 'limit=10' in items_public_url + assert 'offset=0' in items_public_url + + impit_async_client = impit.AsyncClient() + response = await impit_async_client.get(items_public_url, timeout=5) + assert response.status_code == 200 + + await dataset.delete() + assert await apify_client_async.dataset(created_dataset.id).get() is None + + +async def test_dataset_should_create_public_items_non_expiring_url(apify_client_async: ApifyClientAsync) -> None: + created_dataset = await apify_client_async.datasets().get_or_create(name=get_random_resource_name('dataset')) + + dataset = apify_client_async.dataset(created_dataset.id) + items_public_url = await dataset.create_items_public_url() + + assert 'signature=' in items_public_url + + impit_async_client = impit.AsyncClient() + response = await impit_async_client.get(items_public_url, timeout=5) + assert response.status_code == 200 + + await dataset.delete() + assert await apify_client_async.dataset(created_dataset.id).get() is None + + +@parametrized_api_urls +async def test_public_url(api_token: str, api_url: str, api_public_url: str) -> None: + apify_client = ApifyClientAsync(token=api_token, api_url=api_url, api_public_url=api_public_url) + dataset = apify_client.dataset('someID') + + # Mock the API call to return predefined response + mock_response = Mock() + mock_response.json.return_value = json.loads(MOCKED_API_DATASET_RESPONSE) + with mock.patch.object(apify_client._http_client, 'call', return_value=mock_response): + public_url = await dataset.create_items_public_url() + assert public_url == ( + f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/datasets/' + f'someID/items?signature={public_url.split("signature=")[1]}' + ) + + +async def test_list_items_signature( + apify_client_async: ApifyClientAsync, test_dataset_of_another_user: DatasetFixture +) -> None: + dataset = apify_client_async.dataset(dataset_id=test_dataset_of_another_user.id) + + # Permission error without valid signature + with pytest.raises( + ApifyApiError, + match=r"Insufficient permissions for the dataset. Make sure you're passing a " + r'correct API token and that it has the required permissions.', + ): + await dataset.list_items() + + # Dataset content retrieved with correct signature + assert ( + test_dataset_of_another_user.expected_content + == (await dataset.list_items(signature=test_dataset_of_another_user.signature)).items + ) + + +async def test_iterate_items_signature( + apify_client_async: ApifyClientAsync, test_dataset_of_another_user: DatasetFixture +) -> None: + dataset = apify_client_async.dataset(dataset_id=test_dataset_of_another_user.id) + + # Permission error without valid signature + with pytest.raises( + ApifyApiError, + match=r"Insufficient permissions for the dataset. Make sure you're passing a " + r'correct API token and that it has the required permissions.', + ): + [item async for item in dataset.iterate_items()] + + # Dataset content retrieved with correct signature + assert test_dataset_of_another_user.expected_content == [ + item async for item in dataset.iterate_items(signature=test_dataset_of_another_user.signature) + ] + + +async def test_get_items_as_bytes_signature( + apify_client_async: ApifyClientAsync, test_dataset_of_another_user: DatasetFixture +) -> None: + dataset = apify_client_async.dataset(dataset_id=test_dataset_of_another_user.id) + + # Permission error without valid signature + with pytest.raises( + ApifyApiError, + match=r"Insufficient permissions for the dataset. Make sure you're passing a " + r'correct API token and that it has the required permissions.', + ): + await dataset.get_items_as_bytes() + + # Dataset content retrieved with correct signature + raw_data = await dataset.get_items_as_bytes(signature=test_dataset_of_another_user.signature) + assert test_dataset_of_another_user.expected_content == json.loads(raw_data.decode('utf-8')) + + +############# +# NEW TESTS # +############# + + +async def test_dataset_get_or_create_and_get(apify_client_async: ApifyClientAsync) -> None: + """Test creating a dataset and retrieving it.""" + dataset_name = get_random_resource_name('dataset') + + # Create dataset + created_dataset = await apify_client_async.datasets().get_or_create(name=dataset_name) + assert created_dataset is not None + assert created_dataset.id is not None + assert created_dataset.name == dataset_name + + # Get the same dataset + dataset_client = apify_client_async.dataset(created_dataset.id) + retrieved_dataset = await dataset_client.get() + assert retrieved_dataset is not None + assert retrieved_dataset.id == created_dataset.id + assert retrieved_dataset.name == dataset_name + + # Cleanup + await dataset_client.delete() + + +async def test_dataset_update(apify_client_async: ApifyClientAsync) -> None: + """Test updating dataset properties.""" + dataset_name = get_random_resource_name('dataset') + new_name = get_random_resource_name('dataset-updated') + + created_dataset = await apify_client_async.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client_async.dataset(created_dataset.id) + + # Update the name + updated_dataset = await dataset_client.update(name=new_name) + assert updated_dataset is not None + assert updated_dataset.name == new_name + assert updated_dataset.id == created_dataset.id + + # Verify the update persisted + retrieved_dataset = await dataset_client.get() + assert retrieved_dataset is not None + assert retrieved_dataset.name == new_name + + # Cleanup + await dataset_client.delete() + + +async def test_dataset_push_and_list_items(apify_client_async: ApifyClientAsync) -> None: + """Test pushing items to dataset and listing them.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = await apify_client_async.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client_async.dataset(created_dataset.id) + + # Push some items + items_to_push = [ + {'id': 1, 'name': 'Item 1', 'value': 100}, + {'id': 2, 'name': 'Item 2', 'value': 200}, + {'id': 3, 'name': 'Item 3', 'value': 300}, + ] + await dataset_client.push_items(items_to_push) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # List items + items_page = await dataset_client.list_items() + assert items_page is not None + assert len(items_page.items) == 3 + assert items_page.count == 3 + # Note: items_page.total may be 0 immediately after push due to eventual consistency + + # Verify items content + for i, item in enumerate(items_page.items): + assert item['id'] == items_to_push[i]['id'] + assert item['name'] == items_to_push[i]['name'] + assert item['value'] == items_to_push[i]['value'] + + # Cleanup + await dataset_client.delete() + + +async def test_dataset_list_items_with_pagination(apify_client_async: ApifyClientAsync) -> None: + """Test listing items with pagination parameters.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = await apify_client_async.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client_async.dataset(created_dataset.id) + + # Push more items + items_to_push = [{'index': i, 'value': i * 10} for i in range(10)] + await dataset_client.push_items(items_to_push) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # List with limit + items_page = await dataset_client.list_items(limit=5) + assert len(items_page.items) == 5 + assert items_page.count == 5 + # Note: items_page.total may be 0 immediately after push due to eventual consistency + assert items_page.limit == 5 + + # List with offset + items_page_offset = await dataset_client.list_items(offset=5, limit=5) + assert len(items_page_offset.items) == 5 + assert items_page_offset.offset == 5 + # Note: items_page.total may be 0 immediately after push due to eventual consistency + + # Verify different items + assert items_page.items[0]['index'] != items_page_offset.items[0]['index'] + + # Cleanup + await dataset_client.delete() + + +async def test_dataset_list_items_with_fields(apify_client_async: ApifyClientAsync) -> None: + """Test listing items with field filtering.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = await apify_client_async.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client_async.dataset(created_dataset.id) + + # Push items with multiple fields + items_to_push = [ + {'id': 1, 'name': 'Item 1', 'value': 100, 'extra': 'data1'}, + {'id': 2, 'name': 'Item 2', 'value': 200, 'extra': 'data2'}, + ] + await dataset_client.push_items(items_to_push) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # List with fields filter + items_page = await dataset_client.list_items(fields=['id', 'name']) + assert len(items_page.items) == 2 + + # Verify only specified fields are returned + for item in items_page.items: + assert 'id' in item + assert 'name' in item + assert 'value' not in item + assert 'extra' not in item + + # Cleanup + await dataset_client.delete() + + +async def test_dataset_iterate_items(apify_client_async: ApifyClientAsync) -> None: + """Test iterating over dataset items.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = await apify_client_async.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client_async.dataset(created_dataset.id) + + # Push items + items_to_push = [{'index': i} for i in range(5)] + await dataset_client.push_items(items_to_push) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Iterate over items + collected_items = [item async for item in dataset_client.iterate_items()] + + assert len(collected_items) == 5 + for i, item in enumerate(collected_items): + assert item['index'] == i + + # Cleanup + await dataset_client.delete() + + +async def test_dataset_delete_nonexistent(apify_client_async: ApifyClientAsync) -> None: + """Test that getting a deleted dataset returns None.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = await apify_client_async.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client_async.dataset(created_dataset.id) + + # Delete dataset + await dataset_client.delete() + + # Verify it's gone + retrieved_dataset = await dataset_client.get() + assert retrieved_dataset is None + + +async def test_dataset_get_statistics(apify_client_async: ApifyClientAsync) -> None: + """Test getting dataset statistics.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = await apify_client_async.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client_async.dataset(created_dataset.id) + + try: + # Push some items first + items_to_push = [ + {'id': 1, 'name': 'Item 1'}, + {'id': 2, 'name': 'Item 2'}, + ] + await dataset_client.push_items(items_to_push) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Get statistics + statistics = await dataset_client.get_statistics() + + # Verify statistics is returned and properly parsed + assert statistics is not None + + finally: + # Cleanup + await dataset_client.delete() + + +async def test_dataset_stream_items(apify_client_async: ApifyClientAsync) -> None: + """Test streaming dataset items.""" + dataset_name = get_random_resource_name('dataset') + + created_dataset = await apify_client_async.datasets().get_or_create(name=dataset_name) + dataset_client = apify_client_async.dataset(created_dataset.id) + + try: + # Push some items + items_to_push = [ + {'id': 1, 'name': 'Item 1', 'value': 100}, + {'id': 2, 'name': 'Item 2', 'value': 200}, + {'id': 3, 'name': 'Item 3', 'value': 300}, + ] + await dataset_client.push_items(items_to_push) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Stream items using async context manager + async with dataset_client.stream_items(item_format='json') as response: + assert response is not None + assert response.status_code == 200 + content = await response.aread() + items = json.loads(content) + assert len(items) == 3 + assert items[0]['id'] == 1 + + finally: + # Cleanup + await dataset_client.delete() diff --git a/tests/integration/test_dataset_collection.py b/tests/integration/test_dataset_collection.py new file mode 100644 index 00000000..9128b792 --- /dev/null +++ b/tests/integration/test_dataset_collection.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apify_client import ApifyClient + + +def test_datasets_list(apify_client: ApifyClient) -> None: + """Test listing datasets.""" + datasets_page = apify_client.datasets().list(limit=10) + + assert datasets_page is not None + assert datasets_page.items is not None + assert isinstance(datasets_page.items, list) + + +def test_datasets_list_pagination(apify_client: ApifyClient) -> None: + """Test listing datasets with pagination.""" + datasets_page = apify_client.datasets().list(limit=5, offset=0) + + assert datasets_page is not None + assert datasets_page.items is not None + assert isinstance(datasets_page.items, list) + + +def test_datasets_get_or_create(apify_client: ApifyClient) -> None: + """Test get_or_create for datasets.""" + unique_name = f'test-dataset-{uuid.uuid4().hex[:8]}' + + # Create new dataset + dataset = apify_client.datasets().get_or_create(name=unique_name) + assert dataset is not None + assert dataset.name == unique_name + + # Get same dataset again (should return existing) + same_dataset = apify_client.datasets().get_or_create(name=unique_name) + assert same_dataset.id == dataset.id + + # Cleanup + apify_client.dataset(dataset.id).delete() diff --git a/tests/integration/test_dataset_collection_async.py b/tests/integration/test_dataset_collection_async.py new file mode 100644 index 00000000..590dc2fa --- /dev/null +++ b/tests/integration/test_dataset_collection_async.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + + +async def test_datasets_list(apify_client_async: ApifyClientAsync) -> None: + """Test listing datasets.""" + datasets_page = await apify_client_async.datasets().list(limit=10) + + assert datasets_page is not None + assert datasets_page.items is not None + assert isinstance(datasets_page.items, list) + + +async def test_datasets_list_pagination(apify_client_async: ApifyClientAsync) -> None: + """Test listing datasets with pagination.""" + datasets_page = await apify_client_async.datasets().list(limit=5, offset=0) + + assert datasets_page is not None + assert datasets_page.items is not None + assert isinstance(datasets_page.items, list) + + +async def test_datasets_get_or_create(apify_client_async: ApifyClientAsync) -> None: + """Test get_or_create for datasets.""" + unique_name = f'test-dataset-{uuid.uuid4().hex[:8]}' + + # Create new dataset + dataset = await apify_client_async.datasets().get_or_create(name=unique_name) + assert dataset is not None + assert dataset.name == unique_name + + # Get same dataset again (should return existing) + same_dataset = await apify_client_async.datasets().get_or_create(name=unique_name) + assert same_dataset.id == dataset.id + + # Cleanup + await apify_client_async.dataset(dataset.id).delete() diff --git a/tests/integration/test_key_value_store.py b/tests/integration/test_key_value_store.py index 470d8ec8..aee6ef30 100644 --- a/tests/integration/test_key_value_store.py +++ b/tests/integration/test_key_value_store.py @@ -1,18 +1,23 @@ from __future__ import annotations import json +import time from unittest import mock from unittest.mock import Mock import impit import pytest -from apify_shared.utils import create_hmac_signature, create_storage_content_signature -from .integration_test_utils import TestKvs, parametrized_api_urls, random_resource_name -from apify_client import ApifyClient, ApifyClientAsync -from apify_client.client import DEFAULT_API_URL +from .utils import KvsFixture, get_random_resource_name, parametrized_api_urls +from apify_client import ApifyClient +from apify_client._config import DEFAULT_API_URL +from apify_client._utils import create_hmac_signature, create_storage_content_signature from apify_client.errors import ApifyApiError +################################################## +# OLD TESTS - Tests with mocks and signed URLs +################################################## + MOCKED_ID = 'someID' @@ -42,319 +47,376 @@ def _get_mocked_api_kvs_response(signing_key: str | None = None) -> Mock: return mock_response -class TestKeyValueStoreSync: - def test_key_value_store_should_create_expiring_keys_public_url_with_params( - self, apify_client: ApifyClient - ) -> None: - created_store = apify_client.key_value_stores().get_or_create(name=random_resource_name('key-value-store')) +def test_key_value_store_should_create_expiring_keys_public_url_with_params( + apify_client: ApifyClient, +) -> None: + created_store = apify_client.key_value_stores().get_or_create(name=get_random_resource_name('key-value-store')) - store = apify_client.key_value_store(created_store['id']) - keys_public_url = store.create_keys_public_url( - expires_in_secs=2000, - limit=10, - ) + store = apify_client.key_value_store(created_store.id) + keys_public_url = store.create_keys_public_url( + expires_in_secs=2000, + limit=10, + ) - assert 'signature=' in keys_public_url - assert 'limit=10' in keys_public_url - - impit_client = impit.Client() - response = impit_client.get(keys_public_url, timeout=5) - assert response.status_code == 200 - - store.delete() - assert apify_client.key_value_store(created_store['id']).get() is None - - def test_key_value_store_should_create_public_keys_non_expiring_url(self, apify_client: ApifyClient) -> None: - created_store = apify_client.key_value_stores().get_or_create(name=random_resource_name('key-value-store')) - - store = apify_client.key_value_store(created_store['id']) - keys_public_url = store.create_keys_public_url() - - assert 'signature=' in keys_public_url - - impit_client = impit.Client() - response = impit_client.get(keys_public_url, timeout=5) - assert response.status_code == 200 - - store.delete() - assert apify_client.key_value_store(created_store['id']).get() is None - - @pytest.mark.parametrize('signing_key', [None, 'custom-signing-key']) - @parametrized_api_urls - def test_public_url(self, api_token: str, api_url: str, api_public_url: str, signing_key: str) -> None: - apify_client = ApifyClient(token=api_token, api_url=api_url, api_public_url=api_public_url) - kvs = apify_client.key_value_store(MOCKED_ID) - - # Mock the API call to return predefined response - with mock.patch.object( - apify_client.http_client, - 'call', - return_value=_get_mocked_api_kvs_response(signing_key=signing_key), - ): - public_url = kvs.create_keys_public_url() - if signing_key: - signature_value = create_storage_content_signature( - resource_id=MOCKED_ID, url_signing_secret_key=signing_key - ) - expected_signature = f'?signature={signature_value}' - else: - expected_signature = '' - assert public_url == ( - f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/someID/keys{expected_signature}' - ) + assert 'signature=' in keys_public_url + assert 'limit=10' in keys_public_url - @pytest.mark.parametrize('signing_key', [None, 'custom-signing-key']) - @parametrized_api_urls - def test_record_public_url(self, api_token: str, api_url: str, api_public_url: str, signing_key: str) -> None: - apify_client = ApifyClient(token=api_token, api_url=api_url, api_public_url=api_public_url) - key = 'some_key' - kvs = apify_client.key_value_store(MOCKED_ID) - - # Mock the API call to return predefined response - with mock.patch.object( - apify_client.http_client, - 'call', - return_value=_get_mocked_api_kvs_response(signing_key=signing_key), - ): - public_url = kvs.get_record_public_url(key=key) - expected_signature = f'?signature={create_hmac_signature(signing_key, key)}' if signing_key else '' - assert public_url == ( - f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/someID/' - f'records/{key}{expected_signature}' - ) + impit_client = impit.Client() + response = impit_client.get(keys_public_url, timeout=5) + assert response.status_code == 200 - def test_list_keys_signature(self, apify_client: ApifyClient, test_kvs_of_another_user: TestKvs) -> None: - kvs = apify_client.key_value_store(key_value_store_id=test_kvs_of_another_user.id) + store.delete() + assert apify_client.key_value_store(created_store.id).get() is None - # Permission error without valid signature - with pytest.raises( - ApifyApiError, - match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" - r' API token and that it has the required permissions.', - ): - kvs.list_keys() - # Kvs content retrieved with correct signature - raw_items = kvs.list_keys(signature=test_kvs_of_another_user.signature)['items'] +def test_key_value_store_should_create_public_keys_non_expiring_url(apify_client: ApifyClient) -> None: + created_store = apify_client.key_value_stores().get_or_create(name=get_random_resource_name('key-value-store')) - assert set(test_kvs_of_another_user.expected_content) == {item['key'] for item in raw_items} + store = apify_client.key_value_store(created_store.id) + keys_public_url = store.create_keys_public_url() - def test_get_record_signature(self, apify_client: ApifyClient, test_kvs_of_another_user: TestKvs) -> None: - key = 'key1' - kvs = apify_client.key_value_store(key_value_store_id=test_kvs_of_another_user.id) + assert 'signature=' in keys_public_url - # Permission error without valid signature - with pytest.raises( - ApifyApiError, - match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" - r' API token and that it has the required permissions.', - ): - kvs.get_record(key=key) + impit_client = impit.Client() + response = impit_client.get(keys_public_url, timeout=5) + assert response.status_code == 200 - # Kvs content retrieved with correct signature - record = kvs.get_record(key=key, signature=test_kvs_of_another_user.keys_signature[key]) - assert record - assert test_kvs_of_another_user.expected_content[key] == record['value'] + store.delete() + assert apify_client.key_value_store(created_store.id).get() is None - def test_get_record_as_bytes_signature(self, apify_client: ApifyClient, test_kvs_of_another_user: TestKvs) -> None: - key = 'key1' - kvs = apify_client.key_value_store(key_value_store_id=test_kvs_of_another_user.id) - # Permission error without valid signature - with pytest.raises( - ApifyApiError, - match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" - r' API token and that it has the required permissions.', - ): - kvs.get_record_as_bytes(key=key) - - # Kvs content retrieved with correct signature - item = kvs.get_record_as_bytes(key=key, signature=test_kvs_of_another_user.keys_signature[key]) - assert item - assert test_kvs_of_another_user.expected_content[key] == json.loads(item['value'].decode('utf-8')) - - def test_stream_record_signature(self, apify_client: ApifyClient, test_kvs_of_another_user: TestKvs) -> None: - key = 'key1' - kvs = apify_client.key_value_store(key_value_store_id=test_kvs_of_another_user.id) - - # Permission error without valid signature - with ( - pytest.raises( - ApifyApiError, - match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" - r' API token and that it has the required permissions.', - ), - kvs.stream_record(key=key), - ): - pass - - # Kvs content retrieved with correct signature - with kvs.stream_record(key=key, signature=test_kvs_of_another_user.keys_signature[key]) as stream: - assert stream - value = json.loads(stream['value'].content.decode('utf-8')) - assert test_kvs_of_another_user.expected_content[key] == value - - -class TestKeyValueStoreAsync: - async def test_key_value_store_should_create_expiring_keys_public_url_with_params( - self, apify_client_async: ApifyClientAsync - ) -> None: - created_store = await apify_client_async.key_value_stores().get_or_create( - name=random_resource_name('key-value-store') +@pytest.mark.parametrize('signing_key', [None, 'custom-signing-key']) +@parametrized_api_urls +def test_public_url(api_token: str, api_url: str, api_public_url: str, signing_key: str) -> None: + apify_client = ApifyClient(token=api_token, api_url=api_url, api_public_url=api_public_url) + kvs = apify_client.key_value_store(MOCKED_ID) + + # Mock the API call to return predefined response + with mock.patch.object( + apify_client._http_client, + 'call', + return_value=_get_mocked_api_kvs_response(signing_key=signing_key), + ): + public_url = kvs.create_keys_public_url() + if signing_key: + signature_value = create_storage_content_signature( + resource_id=MOCKED_ID, url_signing_secret_key=signing_key + ) + expected_signature = f'?signature={signature_value}' + else: + expected_signature = '' + assert public_url == ( + f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/someID/keys{expected_signature}' ) - store = apify_client_async.key_value_store(created_store['id']) - keys_public_url = await store.create_keys_public_url( - expires_in_secs=2000, - limit=10, + +@pytest.mark.parametrize('signing_key', [None, 'custom-signing-key']) +@parametrized_api_urls +def test_record_public_url(api_token: str, api_url: str, api_public_url: str, signing_key: str) -> None: + apify_client = ApifyClient(token=api_token, api_url=api_url, api_public_url=api_public_url) + key = 'some_key' + kvs = apify_client.key_value_store(MOCKED_ID) + + # Mock the API call to return predefined response + with mock.patch.object( + apify_client._http_client, + 'call', + return_value=_get_mocked_api_kvs_response(signing_key=signing_key), + ): + public_url = kvs.get_record_public_url(key=key) + expected_signature = f'?signature={create_hmac_signature(signing_key, key)}' if signing_key else '' + assert public_url == ( + f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/someID/' + f'records/{key}{expected_signature}' ) - assert 'signature=' in keys_public_url - assert 'limit=10' in keys_public_url - impit_async_client = impit.AsyncClient() - response = await impit_async_client.get(keys_public_url, timeout=5) - assert response.status_code == 200 +def test_list_keys_signature(apify_client: ApifyClient, test_kvs_of_another_user: KvsFixture) -> None: + kvs = apify_client.key_value_store(key_value_store_id=test_kvs_of_another_user.id) - await store.delete() - assert await apify_client_async.key_value_store(created_store['id']).get() is None + # Permission error without valid signature + with pytest.raises( + ApifyApiError, + match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" + r' API token and that it has the required permissions.', + ): + kvs.list_keys() - async def test_key_value_store_should_create_public_keys_non_expiring_url( - self, apify_client_async: ApifyClientAsync - ) -> None: - created_store = await apify_client_async.key_value_stores().get_or_create( - name=random_resource_name('key-value-store') - ) + # Kvs content retrieved with correct signature + response = kvs.list_keys(signature=test_kvs_of_another_user.signature) + raw_items = response.items - store = apify_client_async.key_value_store(created_store['id']) - keys_public_url = await store.create_keys_public_url() - - assert 'signature=' in keys_public_url - - impit_async_client = impit.AsyncClient() - response = await impit_async_client.get(keys_public_url, timeout=5) - assert response.status_code == 200 - - await store.delete() - assert await apify_client_async.key_value_store(created_store['id']).get() is None - - @pytest.mark.parametrize('signing_key', [None, 'custom-signing-key']) - @parametrized_api_urls - async def test_public_url(self, api_token: str, api_url: str, api_public_url: str, signing_key: str) -> None: - apify_client = ApifyClientAsync(token=api_token, api_url=api_url, api_public_url=api_public_url) - kvs = apify_client.key_value_store(MOCKED_ID) - - # Mock the API call to return predefined response - with mock.patch.object( - apify_client.http_client, - 'call', - return_value=_get_mocked_api_kvs_response(signing_key=signing_key), - ): - public_url = await kvs.create_keys_public_url() - if signing_key: - signature_value = create_storage_content_signature( - resource_id=MOCKED_ID, url_signing_secret_key=signing_key - ) - expected_signature = f'?signature={signature_value}' - else: - expected_signature = '' - assert public_url == ( - f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/someID/keys{expected_signature}' - ) + assert set(test_kvs_of_another_user.expected_content) == {item.key for item in raw_items} - @pytest.mark.parametrize('signing_key', [None, 'custom-signing-key']) - @parametrized_api_urls - async def test_record_public_url(self, api_token: str, api_url: str, api_public_url: str, signing_key: str) -> None: - apify_client = ApifyClientAsync(token=api_token, api_url=api_url, api_public_url=api_public_url) - key = 'some_key' - kvs = apify_client.key_value_store(MOCKED_ID) - - # Mock the API call to return predefined response - with mock.patch.object( - apify_client.http_client, - 'call', - return_value=_get_mocked_api_kvs_response(signing_key=signing_key), - ): - public_url = await kvs.get_record_public_url(key=key) - expected_signature = f'?signature={create_hmac_signature(signing_key, key)}' if signing_key else '' - assert public_url == ( - f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/someID/' - f'records/{key}{expected_signature}' - ) - async def test_list_keys_signature( - self, apify_client_async: ApifyClientAsync, test_kvs_of_another_user: TestKvs - ) -> None: - kvs = apify_client_async.key_value_store(key_value_store_id=test_kvs_of_another_user.id) +def test_get_record_signature(apify_client: ApifyClient, test_kvs_of_another_user: KvsFixture) -> None: + key = 'key1' + kvs = apify_client.key_value_store(key_value_store_id=test_kvs_of_another_user.id) - # Permission error without valid signature - with pytest.raises( - ApifyApiError, - match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" - r' API token and that it has the required permissions.', - ): - await kvs.list_keys() + # Permission error without valid signature + with pytest.raises( + ApifyApiError, + match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" + r' API token and that it has the required permissions.', + ): + kvs.get_record(key=key) - # Kvs content retrieved with correct signature - raw_items = (await kvs.list_keys(signature=test_kvs_of_another_user.signature))['items'] + # Kvs content retrieved with correct signature + record = kvs.get_record(key=key, signature=test_kvs_of_another_user.keys_signature[key]) + assert record + assert test_kvs_of_another_user.expected_content[key] == record['value'] - assert set(test_kvs_of_another_user.expected_content) == {item['key'] for item in raw_items} - async def test_get_record_signature( - self, apify_client_async: ApifyClientAsync, test_kvs_of_another_user: TestKvs - ) -> None: - key = 'key1' - kvs = apify_client_async.key_value_store(key_value_store_id=test_kvs_of_another_user.id) +def test_get_record_as_bytes_signature(apify_client: ApifyClient, test_kvs_of_another_user: KvsFixture) -> None: + key = 'key1' + kvs = apify_client.key_value_store(key_value_store_id=test_kvs_of_another_user.id) - # Permission error without valid signature - with pytest.raises( - ApifyApiError, - match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" - r' API token and that it has the required permissions.', - ): - await kvs.get_record(key=key) - - # Kvs content retrieved with correct signature - record = await kvs.get_record(key=key, signature=test_kvs_of_another_user.keys_signature[key]) - assert record - assert test_kvs_of_another_user.expected_content[key] == record['value'] - - async def test_get_record_as_bytes_signature( - self, apify_client_async: ApifyClientAsync, test_kvs_of_another_user: TestKvs - ) -> None: - key = 'key1' - kvs = apify_client_async.key_value_store(key_value_store_id=test_kvs_of_another_user.id) - - # Permission error without valid signature - with pytest.raises( - ApifyApiError, - match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" - r' API token and that it has the required permissions.', - ): - await kvs.get_record_as_bytes(key=key) - - # Kvs content retrieved with correct signature - item = await kvs.get_record_as_bytes(key=key, signature=test_kvs_of_another_user.keys_signature[key]) - assert item - assert test_kvs_of_another_user.expected_content[key] == json.loads(item['value'].decode('utf-8')) - - async def test_stream_record_signature( - self, apify_client_async: ApifyClientAsync, test_kvs_of_another_user: TestKvs - ) -> None: - key = 'key1' - kvs = apify_client_async.key_value_store(key_value_store_id=test_kvs_of_another_user.id) - - # Permission error without valid signature - with pytest.raises( + # Permission error without valid signature + with pytest.raises( + ApifyApiError, + match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" + r' API token and that it has the required permissions.', + ): + kvs.get_record_as_bytes(key=key) + + # Kvs content retrieved with correct signature + item = kvs.get_record_as_bytes(key=key, signature=test_kvs_of_another_user.keys_signature[key]) + assert item + assert test_kvs_of_another_user.expected_content[key] == json.loads(item['value'].decode('utf-8')) + + +def test_stream_record_signature(apify_client: ApifyClient, test_kvs_of_another_user: KvsFixture) -> None: + key = 'key1' + kvs = apify_client.key_value_store(key_value_store_id=test_kvs_of_another_user.id) + + # Permission error without valid signature + with ( + pytest.raises( ApifyApiError, match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" r' API token and that it has the required permissions.', - ): - async with kvs.stream_record(key=key): - pass - - # Kvs content retrieved with correct signature - async with kvs.stream_record(key=key, signature=test_kvs_of_another_user.keys_signature[key]) as stream: - assert stream - value = json.loads(stream['value'].content.decode('utf-8')) - assert test_kvs_of_another_user.expected_content[key] == value + ), + kvs.stream_record(key=key), + ): + pass + + # Kvs content retrieved with correct signature + with kvs.stream_record(key=key, signature=test_kvs_of_another_user.keys_signature[key]) as stream: + assert stream + value = json.loads(stream['value'].content.decode('utf-8')) + assert test_kvs_of_another_user.expected_content[key] == value + + +############# +# NEW TESTS # +############# + + +def test_key_value_store_get_or_create_and_get(apify_client: ApifyClient) -> None: + """Test creating a key-value store and retrieving it.""" + store_name = get_random_resource_name('kvs') + + # Create store + created_store = apify_client.key_value_stores().get_or_create(name=store_name) + assert created_store is not None + assert created_store.id is not None + assert created_store.name == store_name + + # Get the same store + store_client = apify_client.key_value_store(created_store.id) + retrieved_store = store_client.get() + assert retrieved_store is not None + assert retrieved_store.id == created_store.id + assert retrieved_store.name == store_name + + # Cleanup + store_client.delete() + + +def test_key_value_store_update(apify_client: ApifyClient) -> None: + """Test updating key-value store properties.""" + store_name = get_random_resource_name('kvs') + new_name = get_random_resource_name('kvs-updated') + + created_store = apify_client.key_value_stores().get_or_create(name=store_name) + store_client = apify_client.key_value_store(created_store.id) + + # Update the name + updated_store = store_client.update(name=new_name) + assert updated_store is not None + assert updated_store.name == new_name + assert updated_store.id == created_store.id + + # Verify the update persisted + retrieved_store = store_client.get() + assert retrieved_store is not None + assert retrieved_store.name == new_name + + # Cleanup + store_client.delete() + + +def test_key_value_store_set_and_get_record(apify_client: ApifyClient) -> None: + """Test setting and getting records from key-value store.""" + store_name = get_random_resource_name('kvs') + + created_store = apify_client.key_value_stores().get_or_create(name=store_name) + store_client = apify_client.key_value_store(created_store.id) + + # Set a JSON record + test_value = {'name': 'Test Item', 'value': 123, 'nested': {'data': 'value'}} + store_client.set_record('test-key', test_value) + + # Wait briefly for eventual consistency + time.sleep(1) + + # Get the record + record = store_client.get_record('test-key') + assert record is not None + assert record['key'] == 'test-key' + assert record['value'] == test_value + assert 'application/json' in record['content_type'] + + # Cleanup + store_client.delete() + + +def test_key_value_store_set_and_get_text_record(apify_client: ApifyClient) -> None: + """Test setting and getting text records.""" + store_name = get_random_resource_name('kvs') + + created_store = apify_client.key_value_stores().get_or_create(name=store_name) + store_client = apify_client.key_value_store(created_store.id) + + # Set a text record + test_text = 'Hello, this is a test text!' + store_client.set_record('text-key', test_text, content_type='text/plain') + + # Wait briefly for eventual consistency + time.sleep(1) + + # Get the record + record = store_client.get_record('text-key') + assert record is not None + assert record['key'] == 'text-key' + assert record['value'] == test_text + assert 'text/plain' in record['content_type'] + + # Cleanup + store_client.delete() + + +def test_key_value_store_list_keys(apify_client: ApifyClient) -> None: + """Test listing keys in the key-value store.""" + store_name = get_random_resource_name('kvs') + + created_store = apify_client.key_value_stores().get_or_create(name=store_name) + store_client = apify_client.key_value_store(created_store.id) + + # Set multiple records + for i in range(5): + store_client.set_record(f'key-{i}', {'index': i}) + + # Wait briefly for eventual consistency + time.sleep(1) + + # List keys + keys_response = store_client.list_keys() + assert keys_response is not None + assert len(keys_response.items) == 5 + + # Verify key names + key_names = [item.key for item in keys_response.items] + for i in range(5): + assert f'key-{i}' in key_names + + # Cleanup + store_client.delete() + + +def test_key_value_store_list_keys_with_limit(apify_client: ApifyClient) -> None: + """Test listing keys with limit parameter.""" + store_name = get_random_resource_name('kvs') + + created_store = apify_client.key_value_stores().get_or_create(name=store_name) + store_client = apify_client.key_value_store(created_store.id) + + # Set multiple records + for i in range(10): + store_client.set_record(f'item-{i:02d}', {'index': i}) + + # Wait briefly for eventual consistency + time.sleep(1) + + # List with limit + keys_response = store_client.list_keys(limit=5) + assert keys_response is not None + assert len(keys_response.items) == 5 + + # Cleanup + store_client.delete() + + +def test_key_value_store_record_exists(apify_client: ApifyClient) -> None: + """Test checking if a record exists.""" + store_name = get_random_resource_name('kvs') + + created_store = apify_client.key_value_stores().get_or_create(name=store_name) + store_client = apify_client.key_value_store(created_store.id) + + # Set a record + store_client.set_record('exists-key', {'data': 'value'}) + + # Wait briefly for eventual consistency + time.sleep(1) + + # Check existence + assert store_client.record_exists('exists-key') is True + assert store_client.record_exists('non-existent-key') is False + + # Cleanup + store_client.delete() + + +def test_key_value_store_delete_record(apify_client: ApifyClient) -> None: + """Test deleting a record from the store.""" + store_name = get_random_resource_name('kvs') + + created_store = apify_client.key_value_stores().get_or_create(name=store_name) + store_client = apify_client.key_value_store(created_store.id) + + # Set a record + store_client.set_record('delete-me', {'data': 'value'}) + + # Wait briefly for eventual consistency + time.sleep(1) + + # Verify it exists + assert store_client.get_record('delete-me') is not None + + # Delete the record + store_client.delete_record('delete-me') + + # Wait briefly + time.sleep(1) + + # Verify it's gone + assert store_client.get_record('delete-me') is None + + # Cleanup + store_client.delete() + + +def test_key_value_store_delete_nonexistent(apify_client: ApifyClient) -> None: + """Test that getting a deleted store returns None.""" + store_name = get_random_resource_name('kvs') + + created_store = apify_client.key_value_stores().get_or_create(name=store_name) + store_client = apify_client.key_value_store(created_store.id) + + # Delete store + store_client.delete() + + # Verify it's gone + retrieved_store = store_client.get() + assert retrieved_store is None diff --git a/tests/integration/test_key_value_store_async.py b/tests/integration/test_key_value_store_async.py new file mode 100644 index 00000000..9b622952 --- /dev/null +++ b/tests/integration/test_key_value_store_async.py @@ -0,0 +1,431 @@ +from __future__ import annotations + +import asyncio +import json +from unittest import mock +from unittest.mock import Mock + +import impit +import pytest + +from .utils import KvsFixture, get_random_resource_name, parametrized_api_urls +from apify_client import ApifyClientAsync +from apify_client._config import DEFAULT_API_URL +from apify_client._utils import create_hmac_signature, create_storage_content_signature +from apify_client.errors import ApifyApiError + +################################################## +# OLD TESTS - Tests with mocks and signed URLs +################################################## + +MOCKED_ID = 'someID' + + +def _get_mocked_api_kvs_response(signing_key: str | None = None) -> Mock: + response_data = { + 'data': { + 'id': MOCKED_ID, + 'name': 'name', + 'userId': 'userId', + 'createdAt': '2025-09-11T08:48:51.806Z', + 'modifiedAt': '2025-09-11T08:48:51.806Z', + 'accessedAt': '2025-09-11T08:48:51.806Z', + 'actId': None, + 'actRunId': None, + 'schema': None, + 'stats': {'readCount': 0, 'writeCount': 0, 'deleteCount': 0, 'listCount': 0, 'storageBytes': 0}, + 'consoleUrl': 'https://console.apify.com/storage/key-value-stores/someID', + 'keysPublicUrl': 'https://api.apify.com/v2/key-value-stores/someID/keys', + 'generalAccess': 'FOLLOW_USER_SETTING', + } + } + if signing_key: + response_data['data']['urlSigningSecretKey'] = signing_key + + mock_response = Mock() + mock_response.json.return_value = response_data + return mock_response + + +async def test_key_value_store_should_create_expiring_keys_public_url_with_params( + apify_client_async: ApifyClientAsync, +) -> None: + created_store = await apify_client_async.key_value_stores().get_or_create( + name=get_random_resource_name('key-value-store') + ) + + store = apify_client_async.key_value_store(created_store.id) + keys_public_url = await store.create_keys_public_url( + expires_in_secs=2000, + limit=10, + ) + + assert 'signature=' in keys_public_url + assert 'limit=10' in keys_public_url + + impit_async_client = impit.AsyncClient() + response = await impit_async_client.get(keys_public_url, timeout=5) + assert response.status_code == 200 + + await store.delete() + assert await apify_client_async.key_value_store(created_store.id).get() is None + + +async def test_key_value_store_should_create_public_keys_non_expiring_url( + apify_client_async: ApifyClientAsync, +) -> None: + created_store = await apify_client_async.key_value_stores().get_or_create( + name=get_random_resource_name('key-value-store') + ) + + store = apify_client_async.key_value_store(created_store.id) + keys_public_url = await store.create_keys_public_url() + + assert 'signature=' in keys_public_url + + impit_async_client = impit.AsyncClient() + response = await impit_async_client.get(keys_public_url, timeout=5) + assert response.status_code == 200 + + await store.delete() + assert await apify_client_async.key_value_store(created_store.id).get() is None + + +@pytest.mark.parametrize('signing_key', [None, 'custom-signing-key']) +@parametrized_api_urls +async def test_public_url(api_token: str, api_url: str, api_public_url: str, signing_key: str) -> None: + apify_client = ApifyClientAsync(token=api_token, api_url=api_url, api_public_url=api_public_url) + kvs = apify_client.key_value_store(MOCKED_ID) + + # Mock the API call to return predefined response + with mock.patch.object( + apify_client._http_client, + 'call', + return_value=_get_mocked_api_kvs_response(signing_key=signing_key), + ): + public_url = await kvs.create_keys_public_url() + if signing_key: + signature_value = create_storage_content_signature( + resource_id=MOCKED_ID, url_signing_secret_key=signing_key + ) + expected_signature = f'?signature={signature_value}' + else: + expected_signature = '' + assert public_url == ( + f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/someID/keys{expected_signature}' + ) + + +@pytest.mark.parametrize('signing_key', [None, 'custom-signing-key']) +@parametrized_api_urls +async def test_record_public_url(api_token: str, api_url: str, api_public_url: str, signing_key: str) -> None: + apify_client = ApifyClientAsync(token=api_token, api_url=api_url, api_public_url=api_public_url) + key = 'some_key' + kvs = apify_client.key_value_store(MOCKED_ID) + + # Mock the API call to return predefined response + with mock.patch.object( + apify_client._http_client, + 'call', + return_value=_get_mocked_api_kvs_response(signing_key=signing_key), + ): + public_url = await kvs.get_record_public_url(key=key) + expected_signature = f'?signature={create_hmac_signature(signing_key, key)}' if signing_key else '' + assert public_url == ( + f'{(api_public_url or DEFAULT_API_URL).strip("/")}/v2/key-value-stores/someID/' + f'records/{key}{expected_signature}' + ) + + +async def test_list_keys_signature(apify_client_async: ApifyClientAsync, test_kvs_of_another_user: KvsFixture) -> None: + kvs = apify_client_async.key_value_store(key_value_store_id=test_kvs_of_another_user.id) + + # Permission error without valid signature + with pytest.raises( + ApifyApiError, + match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" + r' API token and that it has the required permissions.', + ): + await kvs.list_keys() + + # Kvs content retrieved with correct signature + response = await kvs.list_keys(signature=test_kvs_of_another_user.signature) + raw_items = response.items + + assert set(test_kvs_of_another_user.expected_content) == {item.key for item in raw_items} + + +async def test_get_record_signature(apify_client_async: ApifyClientAsync, test_kvs_of_another_user: KvsFixture) -> None: + key = 'key1' + kvs = apify_client_async.key_value_store(key_value_store_id=test_kvs_of_another_user.id) + + # Permission error without valid signature + with pytest.raises( + ApifyApiError, + match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" + r' API token and that it has the required permissions.', + ): + await kvs.get_record(key=key) + + # Kvs content retrieved with correct signature + record = await kvs.get_record(key=key, signature=test_kvs_of_another_user.keys_signature[key]) + assert record + assert test_kvs_of_another_user.expected_content[key] == record['value'] + + +async def test_get_record_as_bytes_signature( + apify_client_async: ApifyClientAsync, test_kvs_of_another_user: KvsFixture +) -> None: + key = 'key1' + kvs = apify_client_async.key_value_store(key_value_store_id=test_kvs_of_another_user.id) + + # Permission error without valid signature + with pytest.raises( + ApifyApiError, + match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" + r' API token and that it has the required permissions.', + ): + await kvs.get_record_as_bytes(key=key) + + # Kvs content retrieved with correct signature + item = await kvs.get_record_as_bytes(key=key, signature=test_kvs_of_another_user.keys_signature[key]) + assert item + assert test_kvs_of_another_user.expected_content[key] == json.loads(item['value'].decode('utf-8')) + + +async def test_stream_record_signature( + apify_client_async: ApifyClientAsync, + test_kvs_of_another_user: KvsFixture, +) -> None: + key = 'key1' + kvs = apify_client_async.key_value_store(key_value_store_id=test_kvs_of_another_user.id) + + # Permission error without valid signature + with pytest.raises( + ApifyApiError, + match=r"Insufficient permissions for the key-value store. Make sure you're passing a correct" + r' API token and that it has the required permissions.', + ): + async with kvs.stream_record(key=key): + pass + + # Kvs content retrieved with correct signature + async with kvs.stream_record(key=key, signature=test_kvs_of_another_user.keys_signature[key]) as stream: + assert stream + value = json.loads(stream['value'].content.decode('utf-8')) + assert test_kvs_of_another_user.expected_content[key] == value + + +############# +# NEW TESTS # +############# + + +async def test_key_value_store_get_or_create_and_get(apify_client_async: ApifyClientAsync) -> None: + """Test creating a key-value store and retrieving it.""" + store_name = get_random_resource_name('kvs') + + # Create store + created_store = await apify_client_async.key_value_stores().get_or_create(name=store_name) + assert created_store is not None + assert created_store.id is not None + assert created_store.name == store_name + + # Get the same store + store_client = apify_client_async.key_value_store(created_store.id) + retrieved_store = await store_client.get() + assert retrieved_store is not None + assert retrieved_store.id == created_store.id + assert retrieved_store.name == store_name + + # Cleanup + await store_client.delete() + + +async def test_key_value_store_update(apify_client_async: ApifyClientAsync) -> None: + """Test updating key-value store properties.""" + store_name = get_random_resource_name('kvs') + new_name = get_random_resource_name('kvs-updated') + + created_store = await apify_client_async.key_value_stores().get_or_create(name=store_name) + store_client = apify_client_async.key_value_store(created_store.id) + + # Update the name + updated_store = await store_client.update(name=new_name) + assert updated_store is not None + assert updated_store.name == new_name + assert updated_store.id == created_store.id + + # Verify the update persisted + retrieved_store = await store_client.get() + assert retrieved_store is not None + assert retrieved_store.name == new_name + + # Cleanup + await store_client.delete() + + +async def test_key_value_store_set_and_get_record(apify_client_async: ApifyClientAsync) -> None: + """Test setting and getting records from key-value store.""" + store_name = get_random_resource_name('kvs') + + created_store = await apify_client_async.key_value_stores().get_or_create(name=store_name) + store_client = apify_client_async.key_value_store(created_store.id) + + # Set a JSON record + test_value = {'name': 'Test Item', 'value': 123, 'nested': {'data': 'value'}} + await store_client.set_record('test-key', test_value) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Get the record + record = await store_client.get_record('test-key') + assert record is not None + assert record['key'] == 'test-key' + assert record['value'] == test_value + assert 'application/json' in record['content_type'] + + # Cleanup + await store_client.delete() + + +async def test_key_value_store_set_and_get_text_record(apify_client_async: ApifyClientAsync) -> None: + """Test setting and getting text records.""" + store_name = get_random_resource_name('kvs') + + created_store = await apify_client_async.key_value_stores().get_or_create(name=store_name) + store_client = apify_client_async.key_value_store(created_store.id) + + # Set a text record + test_text = 'Hello, this is a test text!' + await store_client.set_record('text-key', test_text, content_type='text/plain') + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Get the record + record = await store_client.get_record('text-key') + assert record is not None + assert record['key'] == 'text-key' + assert record['value'] == test_text + assert 'text/plain' in record['content_type'] + + # Cleanup + await store_client.delete() + + +async def test_key_value_store_list_keys(apify_client_async: ApifyClientAsync) -> None: + """Test listing keys in the key-value store.""" + store_name = get_random_resource_name('kvs') + + created_store = await apify_client_async.key_value_stores().get_or_create(name=store_name) + store_client = apify_client_async.key_value_store(created_store.id) + + # Set multiple records + for i in range(5): + await store_client.set_record(f'key-{i}', {'index': i}) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # List keys + keys_response = await store_client.list_keys() + assert keys_response is not None + assert len(keys_response.items) == 5 + + # Verify key names + key_names = [item.key for item in keys_response.items] + for i in range(5): + assert f'key-{i}' in key_names + + # Cleanup + await store_client.delete() + + +async def test_key_value_store_list_keys_with_limit(apify_client_async: ApifyClientAsync) -> None: + """Test listing keys with limit parameter.""" + store_name = get_random_resource_name('kvs') + + created_store = await apify_client_async.key_value_stores().get_or_create(name=store_name) + store_client = apify_client_async.key_value_store(created_store.id) + + # Set multiple records + for i in range(10): + await store_client.set_record(f'item-{i:02d}', {'index': i}) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # List with limit + keys_response = await store_client.list_keys(limit=5) + assert keys_response is not None + assert len(keys_response.items) == 5 + + # Cleanup + await store_client.delete() + + +async def test_key_value_store_record_exists(apify_client_async: ApifyClientAsync) -> None: + """Test checking if a record exists.""" + store_name = get_random_resource_name('kvs') + + created_store = await apify_client_async.key_value_stores().get_or_create(name=store_name) + store_client = apify_client_async.key_value_store(created_store.id) + + # Set a record + await store_client.set_record('exists-key', {'data': 'value'}) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Check existence + assert await store_client.record_exists('exists-key') is True + assert await store_client.record_exists('non-existent-key') is False + + # Cleanup + await store_client.delete() + + +async def test_key_value_store_delete_record(apify_client_async: ApifyClientAsync) -> None: + """Test deleting a record from the store.""" + store_name = get_random_resource_name('kvs') + + created_store = await apify_client_async.key_value_stores().get_or_create(name=store_name) + store_client = apify_client_async.key_value_store(created_store.id) + + # Set a record + await store_client.set_record('delete-me', {'data': 'value'}) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Verify it exists + assert await store_client.get_record('delete-me') is not None + + # Delete the record + await store_client.delete_record('delete-me') + + # Wait briefly + await asyncio.sleep(1) + + # Verify it's gone + assert await store_client.get_record('delete-me') is None + + # Cleanup + await store_client.delete() + + +async def test_key_value_store_delete_nonexistent(apify_client_async: ApifyClientAsync) -> None: + """Test that getting a deleted store returns None.""" + store_name = get_random_resource_name('kvs') + + created_store = await apify_client_async.key_value_stores().get_or_create(name=store_name) + store_client = apify_client_async.key_value_store(created_store.id) + + # Delete store + await store_client.delete() + + # Verify it's gone + retrieved_store = await store_client.get() + assert retrieved_store is None diff --git a/tests/integration/test_key_value_store_collection.py b/tests/integration/test_key_value_store_collection.py new file mode 100644 index 00000000..37b79f23 --- /dev/null +++ b/tests/integration/test_key_value_store_collection.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apify_client import ApifyClient + + +def test_key_value_stores_list(apify_client: ApifyClient) -> None: + """Test listing key-value stores.""" + kvs_page = apify_client.key_value_stores().list(limit=10) + + assert kvs_page is not None + assert kvs_page.items is not None + assert isinstance(kvs_page.items, list) + + +def test_key_value_stores_list_pagination(apify_client: ApifyClient) -> None: + """Test listing key-value stores with pagination.""" + kvs_page = apify_client.key_value_stores().list(limit=5, offset=0) + + assert kvs_page is not None + assert kvs_page.items is not None + assert isinstance(kvs_page.items, list) + + +def test_key_value_stores_get_or_create(apify_client: ApifyClient) -> None: + """Test get_or_create for key-value stores.""" + unique_name = f'test-kvs-{uuid.uuid4().hex[:8]}' + + # Create new KVS + kvs = apify_client.key_value_stores().get_or_create(name=unique_name) + assert kvs is not None + assert kvs.name == unique_name + + # Get same KVS again (should return existing) + same_kvs = apify_client.key_value_stores().get_or_create(name=unique_name) + assert same_kvs.id == kvs.id + + # Cleanup + apify_client.key_value_store(kvs.id).delete() diff --git a/tests/integration/test_key_value_store_collection_async.py b/tests/integration/test_key_value_store_collection_async.py new file mode 100644 index 00000000..4fd6b6d9 --- /dev/null +++ b/tests/integration/test_key_value_store_collection_async.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + + +async def test_key_value_stores_list(apify_client_async: ApifyClientAsync) -> None: + """Test listing key-value stores.""" + kvs_page = await apify_client_async.key_value_stores().list(limit=10) + + assert kvs_page is not None + assert kvs_page.items is not None + assert isinstance(kvs_page.items, list) + + +async def test_key_value_stores_list_pagination(apify_client_async: ApifyClientAsync) -> None: + """Test listing key-value stores with pagination.""" + kvs_page = await apify_client_async.key_value_stores().list(limit=5, offset=0) + + assert kvs_page is not None + assert kvs_page.items is not None + assert isinstance(kvs_page.items, list) + + +async def test_key_value_stores_get_or_create(apify_client_async: ApifyClientAsync) -> None: + """Test get_or_create for key-value stores.""" + unique_name = f'test-kvs-{uuid.uuid4().hex[:8]}' + + # Create new KVS + kvs = await apify_client_async.key_value_stores().get_or_create(name=unique_name) + assert kvs is not None + assert kvs.name == unique_name + + # Get same KVS again (should return existing) + same_kvs = await apify_client_async.key_value_stores().get_or_create(name=unique_name) + assert same_kvs.id == kvs.id + + # Cleanup + await apify_client_async.key_value_store(kvs.id).delete() diff --git a/tests/integration/test_log.py b/tests/integration/test_log.py new file mode 100644 index 00000000..faad2231 --- /dev/null +++ b/tests/integration/test_log.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apify_client import ApifyClient + +# Use a simple, fast public actor for testing +HELLO_WORLD_ACTOR = 'apify/hello-world' + + +def test_log_get_from_run(apify_client: ApifyClient) -> None: + """Test retrieving log from an actor run.""" + # Run hello-world actor + actor = apify_client.actor(HELLO_WORLD_ACTOR) + run = actor.call() + assert run is not None + + # Get log as text + run_client = apify_client.run(run.id) + log = run_client.log().get() + + assert log is not None + assert isinstance(log, str) + assert len(log) > 0 + + # Cleanup + run_client.delete() + + +def test_log_get_from_build(apify_client: ApifyClient) -> None: + """Test retrieving log from a build.""" + # Get a build from hello-world actor + actor = apify_client.actor(HELLO_WORLD_ACTOR) + builds_page = actor.builds().list(limit=1) + assert builds_page.items + build_id = builds_page.items[0].id + + # Get log from the build + build = apify_client.build(build_id) + log = build.log().get() + + # Build log may be None or empty for some builds + if log is not None: + assert isinstance(log, str) + + +def test_log_get_as_bytes(apify_client: ApifyClient) -> None: + """Test retrieving log as raw bytes.""" + # Run hello-world actor + actor = apify_client.actor(HELLO_WORLD_ACTOR) + run = actor.call() + assert run is not None + + # Get log as bytes + run_client = apify_client.run(run.id) + log_bytes = run_client.log().get_as_bytes() + + assert log_bytes is not None + assert isinstance(log_bytes, bytes) + assert len(log_bytes) > 0 + + # Cleanup + run_client.delete() diff --git a/tests/integration/test_log_async.py b/tests/integration/test_log_async.py new file mode 100644 index 00000000..27adc191 --- /dev/null +++ b/tests/integration/test_log_async.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + +# Use a simple, fast public actor for testing +HELLO_WORLD_ACTOR = 'apify/hello-world' + + +async def test_log_get_from_run(apify_client_async: ApifyClientAsync) -> None: + """Test retrieving log from an actor run.""" + # Run hello-world actor + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + run = await actor.call() + assert run is not None + + # Get log as text + run_client = apify_client_async.run(run.id) + log = await run_client.log().get() + + assert log is not None + assert isinstance(log, str) + assert len(log) > 0 + + # Cleanup + await run_client.delete() + + +async def test_log_get_from_build(apify_client_async: ApifyClientAsync) -> None: + """Test retrieving log from a build.""" + # Get a build from hello-world actor + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + builds_page = await actor.builds().list(limit=1) + assert builds_page.items + build_id = builds_page.items[0].id + + # Get log from the build + build = apify_client_async.build(build_id) + log = await build.log().get() + + # Build log may be None or empty for some builds + if log is not None: + assert isinstance(log, str) + + +async def test_log_get_as_bytes(apify_client_async: ApifyClientAsync) -> None: + """Test retrieving log as raw bytes.""" + # Run hello-world actor + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + run = await actor.call() + assert run is not None + + # Get log as bytes + run_client = apify_client_async.run(run.id) + log_bytes = await run_client.log().get_as_bytes() + + assert log_bytes is not None + assert isinstance(log_bytes, bytes) + assert len(log_bytes) > 0 + + # Cleanup + await run_client.delete() diff --git a/tests/integration/test_request_queue.py b/tests/integration/test_request_queue.py index 64759e47..2b117f7f 100644 --- a/tests/integration/test_request_queue.py +++ b/tests/integration/test_request_queue.py @@ -1,115 +1,460 @@ from __future__ import annotations +import time from typing import TYPE_CHECKING -from integration.integration_test_utils import random_resource_name, random_string +from .utils import get_random_resource_name, get_random_string if TYPE_CHECKING: - from apify_client import ApifyClient, ApifyClientAsync - - -class TestRequestQueueSync: - def test_request_queue_lock(self, apify_client: ApifyClient) -> None: - created_queue = apify_client.request_queues().get_or_create(name=random_resource_name('queue')) - queue = apify_client.request_queue(created_queue['id'], client_key=random_string(10)) - - # Add requests and check if correct number of requests was locked - for i in range(15): - queue.add_request({'url': f'http://test-lock.com/{i}', 'uniqueKey': f'http://test-lock.com/{i}'}) - locked_requests_list = queue.list_and_lock_head(limit=10, lock_secs=10) - locked_requests = locked_requests_list['items'] - for locked_request in locked_requests: - assert locked_request['lockExpiresAt'] is not None - - # Check if the delete request works - queue.delete_request_lock(locked_requests[1]['id']) - delete_lock_request = queue.get_request(locked_requests[1]['id']) - assert delete_lock_request is not None - assert delete_lock_request.get('lockExpiresAt') is None - queue.delete_request_lock(locked_requests[2]['id'], forefront=True) - delete_lock_request2 = queue.get_request(locked_requests[2]['id']) - assert delete_lock_request2 is not None - assert delete_lock_request2.get('lockExpiresAt') is None - - # Check if the prolong request works - assert queue.prolong_request_lock(locked_requests[3]['id'], lock_secs=15)['lockExpiresAt'] is not None - - queue.delete() - assert apify_client.request_queue(created_queue['id']).get() is None - - def test_request_batch_operations(self, apify_client: ApifyClient) -> None: - created_queue = apify_client.request_queues().get_or_create(name=random_resource_name('queue')) - queue = apify_client.request_queue(created_queue['id']) - - # Add requests to queue and check if they were added - requests_to_add = [ - {'url': f'http://test-batch.com/{i}', 'uniqueKey': f'http://test-batch.com/{i}'} for i in range(25) - ] - added_requests = queue.batch_add_requests(requests_to_add) - assert len(added_requests.get('processedRequests', [])) > 0 - requests_in_queue = queue.list_requests() - assert len(requests_in_queue['items']) == len(added_requests['processedRequests']) - - # Delete requests from queue and check if they were deleted - requests_to_delete = requests_in_queue['items'][:20] - delete_response = queue.batch_delete_requests( - [{'uniqueKey': req.get('uniqueKey')} for req in requests_to_delete] + from apify_client import ApifyClient + +################################################## +# OLD TESTS - Tests with mocks and signed URLs +################################################## + + +def test_request_queue_lock(apify_client: ApifyClient) -> None: + created_rq = apify_client.request_queues().get_or_create(name=get_random_resource_name('queue')) + rq = apify_client.request_queue(created_rq.id, client_key=get_random_string(10)) + + # Add requests and check if correct number of requests was locked + for i in range(15): + rq.add_request({'url': f'http://test-lock.com/{i}', 'uniqueKey': f'http://test-lock.com/{i}'}) + + get_head_and_lock_response = rq.list_and_lock_head(limit=10, lock_secs=10) + + for locked_request in get_head_and_lock_response.items: + assert locked_request.lock_expires_at is not None + + # Check if the delete request works + rq.delete_request_lock(get_head_and_lock_response.items[1].id) + + """This is probably not working: + delete_lock_request = rq.get_request(get_head_and_lock_response.items[1].id) + assert delete_lock_request is not None + assert delete_lock_request.lock_expires_at is None + """ + + rq.delete_request_lock(get_head_and_lock_response.items[2].id, forefront=True) + + """This is probably not working: + delete_lock_request2 = rq.get_request(get_head_and_lock_response.items[2].id) + assert delete_lock_request2 is not None + assert delete_lock_request2.lock_expires_at is None + """ + + # Check if the prolong request works + prolong_request_lock_response = rq.prolong_request_lock( + get_head_and_lock_response.items[3].id, + lock_secs=15, + ) + assert prolong_request_lock_response is not None + assert prolong_request_lock_response.lock_expires_at is not None + + rq.delete() + assert apify_client.request_queue(created_rq.id).get() is None + + +############# +# NEW TESTS # +############# + + +def test_request_queue_get_or_create_and_get(apify_client: ApifyClient) -> None: + """Test creating a request queue and retrieving it.""" + rq_name = get_random_resource_name('queue') + + # Create queue + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + assert created_rq is not None + assert created_rq.id is not None + assert created_rq.name == rq_name + + # Get the same queue + rq_client = apify_client.request_queue(created_rq.id) + retrieved_rq = rq_client.get() + assert retrieved_rq is not None + assert retrieved_rq.id == created_rq.id + assert retrieved_rq.name == rq_name + + # Cleanup + rq_client.delete() + + +def test_request_queue_update(apify_client: ApifyClient) -> None: + """Test updating request queue properties.""" + rq_name = get_random_resource_name('queue') + new_name = get_random_resource_name('queue-updated') + + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + rq_client = apify_client.request_queue(created_rq.id) + + # Update the name + updated_rq = rq_client.update(name=new_name) + assert updated_rq is not None + assert updated_rq.name == new_name + assert updated_rq.id == created_rq.id + + # Verify the update persisted + retrieved_rq = rq_client.get() + assert retrieved_rq is not None + assert retrieved_rq.name == new_name + + # Cleanup + rq_client.delete() + + +def test_request_queue_add_and_get_request(apify_client: ApifyClient) -> None: + """Test adding and getting a request from the queue.""" + rq_name = get_random_resource_name('queue') + + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + rq_client = apify_client.request_queue(created_rq.id) + + # Add a request + request_data = { + 'url': 'https://example.com/test', + 'uniqueKey': 'test-key-1', + 'method': 'GET', + } + add_result = rq_client.add_request(request_data) + assert add_result is not None + assert add_result.request_id is not None + assert add_result.was_already_present is False + + # Wait briefly for eventual consistency + time.sleep(1) + + # Get the request + request = rq_client.get_request(add_result.request_id) + assert request is not None + assert str(request.url) == 'https://example.com/test' + assert request.unique_key == 'test-key-1' + + # Cleanup + rq_client.delete() + + +def test_request_queue_list_head(apify_client: ApifyClient) -> None: + """Test listing requests from the head of the queue.""" + rq_name = get_random_resource_name('queue') + + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + rq_client = apify_client.request_queue(created_rq.id) + + # Add multiple requests + for i in range(5): + rq_client.add_request( + { + 'url': f'https://example.com/page-{i}', + 'uniqueKey': f'page-{i}', + } ) - requests_in_queue2 = queue.list_requests() - assert len(requests_in_queue2['items']) == 25 - len(delete_response['processedRequests']) - - queue.delete() - - -class TestRequestQueueAsync: - async def test_request_queue_lock(self, apify_client_async: ApifyClientAsync) -> None: - created_queue = await apify_client_async.request_queues().get_or_create(name=random_resource_name('queue')) - queue = apify_client_async.request_queue(created_queue['id'], client_key=random_string(10)) - - # Add requests and check if correct number of requests was locked - for i in range(15): - await queue.add_request({'url': f'http://test-lock.com/{i}', 'uniqueKey': f'http://test-lock.com/{i}'}) - locked_requests_list = await queue.list_and_lock_head(limit=10, lock_secs=10) - locked_requests = locked_requests_list['items'] - for locked_request in locked_requests: - assert locked_request['lockExpiresAt'] is not None - - # Check if the delete request works - await queue.delete_request_lock(locked_requests[1]['id']) - delete_lock_request = await queue.get_request(locked_requests[1]['id']) - assert delete_lock_request is not None - assert delete_lock_request.get('lockExpiresAt') is None - await queue.delete_request_lock(locked_requests[2]['id'], forefront=True) - delete_lock_request2 = await queue.get_request(locked_requests[2]['id']) - assert delete_lock_request2 is not None - assert delete_lock_request2.get('lockExpiresAt') is None - - # Check if the prolong request works - prolonged_request = await queue.prolong_request_lock(locked_requests[3]['id'], lock_secs=15) - assert prolonged_request['lockExpiresAt'] is not None - - await queue.delete() - assert await apify_client_async.request_queue(created_queue['id']).get() is None - - async def test_request_batch_operations(self, apify_client_async: ApifyClientAsync) -> None: - created_queue = await apify_client_async.request_queues().get_or_create(name=random_resource_name('queue')) - queue = apify_client_async.request_queue(created_queue['id']) - - # Add requests to queue and check if they were added - requests_to_add = [ - {'url': f'http://test-batch.com/{i}', 'uniqueKey': f'http://test-batch.com/{i}'} for i in range(25) - ] - added_requests = await queue.batch_add_requests(requests_to_add) - assert len(added_requests.get('processedRequests', [])) > 0 - requests_in_queue = await queue.list_requests() - assert len(requests_in_queue['items']) == len(added_requests['processedRequests']) - - # Delete requests from queue and check if they were deleted - requests_to_delete = requests_in_queue['items'][:20] - delete_response = await queue.batch_delete_requests( - [{'uniqueKey': req.get('uniqueKey')} for req in requests_to_delete] + + # Wait briefly for eventual consistency + time.sleep(1) + + # List head + head_response = rq_client.list_head(limit=3) + assert head_response is not None + assert len(head_response.items) == 3 + + # Cleanup + rq_client.delete() + + +def test_request_queue_list_requests(apify_client: ApifyClient) -> None: + """Test listing all requests in the queue.""" + rq_name = get_random_resource_name('queue') + + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + rq_client = apify_client.request_queue(created_rq.id) + + # Add multiple requests + for i in range(5): + rq_client.add_request( + { + 'url': f'https://example.com/item-{i}', + 'uniqueKey': f'item-{i}', + } ) - requests_in_queue2 = await queue.list_requests() - assert len(requests_in_queue2['items']) == 25 - len(delete_response['processedRequests']) - await queue.delete() + # Wait briefly for eventual consistency + time.sleep(1) + + # List all requests + list_response = rq_client.list_requests() + assert list_response is not None + assert len(list_response.items) == 5 + + # Cleanup + rq_client.delete() + + +def test_request_queue_delete_request(apify_client: ApifyClient) -> None: + """Test deleting a request from the queue.""" + rq_name = get_random_resource_name('queue') + + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + rq_client = apify_client.request_queue(created_rq.id) + + # Add a request + add_result = rq_client.add_request( + { + 'url': 'https://example.com/to-delete', + 'uniqueKey': 'delete-me', + } + ) + + # Wait briefly for eventual consistency + time.sleep(1) + + # Verify it exists + request = rq_client.get_request(add_result.request_id) + assert request is not None + + # Delete the request + rq_client.delete_request(add_result.request_id) + + # Wait briefly + time.sleep(1) + + # Verify it's gone + deleted_request = rq_client.get_request(add_result.request_id) + assert deleted_request is None + + # Cleanup + rq_client.delete() + + +def test_request_queue_batch_add_requests(apify_client: ApifyClient) -> None: + """Test adding multiple requests in batch.""" + rq_name = get_random_resource_name('queue') + + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + rq_client = apify_client.request_queue(created_rq.id) + + # Batch add requests + requests_to_add = [{'url': f'https://example.com/batch-{i}', 'uniqueKey': f'batch-{i}'} for i in range(10)] + batch_response = rq_client.batch_add_requests(requests_to_add) + assert batch_response is not None + assert len(batch_response.processed_requests) == 10 + assert len(batch_response.unprocessed_requests) == 0 + + # Wait briefly for eventual consistency + time.sleep(1) + + # Verify requests were added + list_response = rq_client.list_requests() + assert len(list_response.items) == 10 + + # Cleanup + rq_client.delete() + + +def test_request_queue_batch_delete_requests(apify_client: ApifyClient) -> None: + """Test deleting multiple requests in batch.""" + rq_name = get_random_resource_name('queue') + + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + rq_client = apify_client.request_queue(created_rq.id) + + # Add requests + for i in range(10): + rq_client.add_request( + { + 'url': f'https://example.com/delete-{i}', + 'uniqueKey': f'delete-{i}', + } + ) + + # Wait briefly for eventual consistency + time.sleep(1) + + # List requests to get IDs + list_response = rq_client.list_requests() + requests_to_delete = [{'uniqueKey': item.unique_key} for item in list_response.items[:5]] + + # Batch delete + delete_response = rq_client.batch_delete_requests(requests_to_delete) + assert delete_response is not None + assert len(delete_response.processed_requests) == 5 + + # Wait briefly + time.sleep(1) + + # Verify remaining requests + remaining = rq_client.list_requests() + assert len(remaining.items) == 5 + + # Cleanup + rq_client.delete() + + +def test_request_queue_delete_nonexistent(apify_client: ApifyClient) -> None: + """Test that getting a deleted queue returns None.""" + rq_name = get_random_resource_name('queue') + + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + rq_client = apify_client.request_queue(created_rq.id) + + # Delete queue + rq_client.delete() + + # Verify it's gone + retrieved_rq = rq_client.get() + assert retrieved_rq is None + + +def test_request_queue_list_and_lock_head(apify_client: ApifyClient) -> None: + """Test locking requests from the head of the queue.""" + rq_name = get_random_resource_name('queue') + + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + rq_client = apify_client.request_queue(created_rq.id, client_key=get_random_string(10)) + + # Add multiple requests + for i in range(5): + rq_client.add_request({'url': f'https://example.com/lock-{i}', 'uniqueKey': f'lock-{i}'}) + + # Wait briefly for eventual consistency + time.sleep(1) + + # Lock head requests + lock_response = rq_client.list_and_lock_head(limit=3, lock_secs=60) + assert lock_response is not None + assert len(lock_response.items) == 3 + + # Verify requests are locked + for locked_request in lock_response.items: + assert locked_request.id is not None + assert locked_request.lock_expires_at is not None + + # Cleanup + rq_client.delete() + + +def test_request_queue_prolong_request_lock(apify_client: ApifyClient) -> None: + """Test prolonging a request lock.""" + rq_name = get_random_resource_name('queue') + + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + rq_client = apify_client.request_queue(created_rq.id, client_key=get_random_string(10)) + + # Add a request + rq_client.add_request({'url': 'https://example.com/prolong', 'uniqueKey': 'prolong-test'}) + + # Wait briefly for eventual consistency + time.sleep(1) + + # Lock the request + lock_response = rq_client.list_and_lock_head(limit=1, lock_secs=60) + assert len(lock_response.items) == 1 + locked_request = lock_response.items[0] + original_lock_expires = locked_request.lock_expires_at + + # Prolong the lock + prolong_response = rq_client.prolong_request_lock(locked_request.id, lock_secs=120) + assert prolong_response is not None + assert prolong_response.lock_expires_at is not None + assert prolong_response.lock_expires_at > original_lock_expires + + # Cleanup + rq_client.delete() + + +def test_request_queue_delete_request_lock(apify_client: ApifyClient) -> None: + """Test deleting a request lock.""" + rq_name = get_random_resource_name('queue') + + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + rq_client = apify_client.request_queue(created_rq.id, client_key=get_random_string(10)) + + # Add a request + rq_client.add_request({'url': 'https://example.com/unlock', 'uniqueKey': 'unlock-test'}) + + # Wait briefly for eventual consistency + time.sleep(1) + + # Lock the request + lock_response = rq_client.list_and_lock_head(limit=1, lock_secs=60) + assert len(lock_response.items) == 1 + locked_request = lock_response.items[0] + + # Delete the lock + rq_client.delete_request_lock(locked_request.id) + + # Verify the operation succeeded (no exception thrown) + # The request should still exist but be unlocked + request = rq_client.get_request(locked_request.id) + assert request is not None + + # Cleanup + rq_client.delete() + + +def test_request_queue_unlock_requests(apify_client: ApifyClient) -> None: + """Test unlocking all requests locked by the client.""" + rq_name = get_random_resource_name('queue') + + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + rq_client = apify_client.request_queue(created_rq.id, client_key=get_random_string(10)) + + # Add multiple requests + for i in range(5): + rq_client.add_request({'url': f'https://example.com/unlock-{i}', 'uniqueKey': f'unlock-{i}'}) + + # Wait briefly for eventual consistency + time.sleep(1) + + # Lock some requests + lock_response = rq_client.list_and_lock_head(limit=3, lock_secs=60) + assert len(lock_response.items) == 3 + + # Unlock all requests + unlock_response = rq_client.unlock_requests() + assert unlock_response is not None + assert unlock_response.unlocked_count == 3 + + # Cleanup + rq_client.delete() + + +def test_request_queue_update_request(apify_client: ApifyClient) -> None: + """Test updating a request in the queue.""" + rq_name = get_random_resource_name('queue') + + created_rq = apify_client.request_queues().get_or_create(name=rq_name) + rq_client = apify_client.request_queue(created_rq.id) + + # Add a request + request_data = { + 'url': 'https://example.com/original', + 'uniqueKey': 'update-test', + 'method': 'GET', + } + add_result = rq_client.add_request(request_data) + assert add_result is not None + assert add_result.request_id is not None + + # Wait briefly for eventual consistency + time.sleep(1) + + # Get the request to get its full data + original_request = rq_client.get_request(add_result.request_id) + assert original_request is not None + + # Update the request (change method and add user data) + updated_request_data = { + 'id': add_result.request_id, + 'url': str(original_request.url), + 'uniqueKey': original_request.unique_key, + 'method': 'POST', + 'userData': {'updated': True}, + } + update_result = rq_client.update_request(updated_request_data) + assert update_result is not None + assert update_result.request_id == add_result.request_id + + # Cleanup + rq_client.delete() diff --git a/tests/integration/test_request_queue_async.py b/tests/integration/test_request_queue_async.py new file mode 100644 index 00000000..77369785 --- /dev/null +++ b/tests/integration/test_request_queue_async.py @@ -0,0 +1,461 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from .utils import get_random_resource_name, get_random_string + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + +################################################## +# OLD TESTS - Tests with mocks and signed URLs +################################################## + + +async def test_request_queue_lock(apify_client_async: ApifyClientAsync) -> None: + created_rq = await apify_client_async.request_queues().get_or_create(name=get_random_resource_name('queue')) + rq = apify_client_async.request_queue(created_rq.id, client_key=get_random_string(10)) + + # Add requests and check if correct number of requests was locked + for i in range(15): + await rq.add_request({'url': f'http://test-lock.com/{i}', 'uniqueKey': f'http://test-lock.com/{i}'}) + + get_head_and_lock_response = await rq.list_and_lock_head(limit=10, lock_secs=10) + + for locked_request in get_head_and_lock_response.items: + assert locked_request.lock_expires_at is not None + + # Check if the delete request works + await rq.delete_request_lock(get_head_and_lock_response.items[1].id) + + """This is probably not working: + delete_lock_request = await rq.get_request(get_head_and_lock_response.data.items[1].id) + assert delete_lock_request is not None + assert delete_lock_request.lock_expires_at is None + """ + + await rq.delete_request_lock(get_head_and_lock_response.items[2].id, forefront=True) + + """This is probably not working: + delete_lock_request2 = await rq.get_request(get_head_and_lock_response.data.items[2].id) + assert delete_lock_request2 is not None + assert delete_lock_request2.lock_expires_at is None + """ + + # Check if the prolong request works + prolong_request_lock_response = await rq.prolong_request_lock( + get_head_and_lock_response.items[3].id, + lock_secs=15, + ) + assert prolong_request_lock_response is not None + assert prolong_request_lock_response.lock_expires_at is not None + + await rq.delete() + assert await apify_client_async.request_queue(created_rq.id).get() is None + + +############# +# NEW TESTS # +############# + + +async def test_request_queue_get_or_create_and_get(apify_client_async: ApifyClientAsync) -> None: + """Test creating a request queue and retrieving it.""" + rq_name = get_random_resource_name('queue') + + # Create queue + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + assert created_rq is not None + assert created_rq.id is not None + assert created_rq.name == rq_name + + # Get the same queue + rq_client = apify_client_async.request_queue(created_rq.id) + retrieved_rq = await rq_client.get() + assert retrieved_rq is not None + assert retrieved_rq.id == created_rq.id + assert retrieved_rq.name == rq_name + + # Cleanup + await rq_client.delete() + + +async def test_request_queue_update(apify_client_async: ApifyClientAsync) -> None: + """Test updating request queue properties.""" + rq_name = get_random_resource_name('queue') + new_name = get_random_resource_name('queue-updated') + + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + rq_client = apify_client_async.request_queue(created_rq.id) + + # Update the name + updated_rq = await rq_client.update(name=new_name) + assert updated_rq is not None + assert updated_rq.name == new_name + assert updated_rq.id == created_rq.id + + # Verify the update persisted + retrieved_rq = await rq_client.get() + assert retrieved_rq is not None + assert retrieved_rq.name == new_name + + # Cleanup + await rq_client.delete() + + +async def test_request_queue_add_and_get_request(apify_client_async: ApifyClientAsync) -> None: + """Test adding and getting a request from the queue.""" + rq_name = get_random_resource_name('queue') + + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + rq_client = apify_client_async.request_queue(created_rq.id) + + # Add a request + request_data = { + 'url': 'https://example.com/test', + 'uniqueKey': 'test-key-1', + 'method': 'GET', + } + add_result = await rq_client.add_request(request_data) + assert add_result is not None + assert add_result.request_id is not None + assert add_result.was_already_present is False + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Get the request + request = await rq_client.get_request(add_result.request_id) + assert request is not None + assert str(request.url) == 'https://example.com/test' + assert request.unique_key == 'test-key-1' + + # Cleanup + await rq_client.delete() + + +async def test_request_queue_list_head(apify_client_async: ApifyClientAsync) -> None: + """Test listing requests from the head of the queue.""" + rq_name = get_random_resource_name('queue') + + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + rq_client = apify_client_async.request_queue(created_rq.id) + + # Add multiple requests + for i in range(5): + await rq_client.add_request( + { + 'url': f'https://example.com/page-{i}', + 'uniqueKey': f'page-{i}', + } + ) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # List head + head_response = await rq_client.list_head(limit=3) + assert head_response is not None + assert len(head_response.items) == 3 + + # Cleanup + await rq_client.delete() + + +async def test_request_queue_list_requests(apify_client_async: ApifyClientAsync) -> None: + """Test listing all requests in the queue.""" + rq_name = get_random_resource_name('queue') + + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + rq_client = apify_client_async.request_queue(created_rq.id) + + # Add multiple requests + for i in range(5): + await rq_client.add_request( + { + 'url': f'https://example.com/item-{i}', + 'uniqueKey': f'item-{i}', + } + ) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # List all requests + list_response = await rq_client.list_requests() + assert list_response is not None + assert len(list_response.items) == 5 + + # Cleanup + await rq_client.delete() + + +async def test_request_queue_delete_request(apify_client_async: ApifyClientAsync) -> None: + """Test deleting a request from the queue.""" + rq_name = get_random_resource_name('queue') + + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + rq_client = apify_client_async.request_queue(created_rq.id) + + # Add a request + add_result = await rq_client.add_request( + { + 'url': 'https://example.com/to-delete', + 'uniqueKey': 'delete-me', + } + ) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Verify it exists + request = await rq_client.get_request(add_result.request_id) + assert request is not None + + # Delete the request + await rq_client.delete_request(add_result.request_id) + + # Wait briefly + await asyncio.sleep(1) + + # Verify it's gone + deleted_request = await rq_client.get_request(add_result.request_id) + assert deleted_request is None + + # Cleanup + await rq_client.delete() + + +async def test_request_queue_batch_add_requests(apify_client_async: ApifyClientAsync) -> None: + """Test adding multiple requests in batch.""" + rq_name = get_random_resource_name('queue') + + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + rq_client = apify_client_async.request_queue(created_rq.id) + + # Batch add requests + requests_to_add = [{'url': f'https://example.com/batch-{i}', 'uniqueKey': f'batch-{i}'} for i in range(10)] + batch_response = await rq_client.batch_add_requests(requests_to_add) + assert batch_response is not None + assert len(batch_response.processed_requests) == 10 + assert len(batch_response.unprocessed_requests) == 0 + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Verify requests were added + list_response = await rq_client.list_requests() + assert len(list_response.items) == 10 + + # Cleanup + await rq_client.delete() + + +async def test_request_queue_batch_delete_requests(apify_client_async: ApifyClientAsync) -> None: + """Test deleting multiple requests in batch.""" + rq_name = get_random_resource_name('queue') + + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + rq_client = apify_client_async.request_queue(created_rq.id) + + # Add requests + for i in range(10): + await rq_client.add_request( + { + 'url': f'https://example.com/delete-{i}', + 'uniqueKey': f'delete-{i}', + } + ) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # List requests to get IDs + list_response = await rq_client.list_requests() + requests_to_delete = [{'uniqueKey': item.unique_key} for item in list_response.items[:5]] + + # Batch delete + delete_response = await rq_client.batch_delete_requests(requests_to_delete) + assert delete_response is not None + assert len(delete_response.processed_requests) == 5 + + # Wait briefly + await asyncio.sleep(1) + + # Verify remaining requests + remaining = await rq_client.list_requests() + assert len(remaining.items) == 5 + + # Cleanup + await rq_client.delete() + + +async def test_request_queue_delete_nonexistent(apify_client_async: ApifyClientAsync) -> None: + """Test that getting a deleted queue returns None.""" + rq_name = get_random_resource_name('queue') + + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + rq_client = apify_client_async.request_queue(created_rq.id) + + # Delete queue + await rq_client.delete() + + # Verify it's gone + retrieved_rq = await rq_client.get() + assert retrieved_rq is None + + +async def test_request_queue_list_and_lock_head(apify_client_async: ApifyClientAsync) -> None: + """Test locking requests from the head of the queue.""" + rq_name = get_random_resource_name('queue') + + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + rq_client = apify_client_async.request_queue(created_rq.id, client_key=get_random_string(10)) + + # Add multiple requests + for i in range(5): + await rq_client.add_request({'url': f'https://example.com/lock-{i}', 'uniqueKey': f'lock-{i}'}) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Lock head requests + lock_response = await rq_client.list_and_lock_head(limit=3, lock_secs=60) + assert lock_response is not None + assert len(lock_response.items) == 3 + + # Verify requests are locked + for locked_request in lock_response.items: + assert locked_request.id is not None + assert locked_request.lock_expires_at is not None + + # Cleanup + await rq_client.delete() + + +async def test_request_queue_prolong_request_lock(apify_client_async: ApifyClientAsync) -> None: + """Test prolonging a request lock.""" + rq_name = get_random_resource_name('queue') + + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + rq_client = apify_client_async.request_queue(created_rq.id, client_key=get_random_string(10)) + + # Add a request + await rq_client.add_request({'url': 'https://example.com/prolong', 'uniqueKey': 'prolong-test'}) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Lock the request + lock_response = await rq_client.list_and_lock_head(limit=1, lock_secs=60) + assert len(lock_response.items) == 1 + locked_request = lock_response.items[0] + original_lock_expires = locked_request.lock_expires_at + + # Prolong the lock + prolong_response = await rq_client.prolong_request_lock(locked_request.id, lock_secs=120) + assert prolong_response is not None + assert prolong_response.lock_expires_at is not None + assert prolong_response.lock_expires_at > original_lock_expires + + # Cleanup + await rq_client.delete() + + +async def test_request_queue_delete_request_lock(apify_client_async: ApifyClientAsync) -> None: + """Test deleting a request lock.""" + rq_name = get_random_resource_name('queue') + + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + rq_client = apify_client_async.request_queue(created_rq.id, client_key=get_random_string(10)) + + # Add a request + await rq_client.add_request({'url': 'https://example.com/unlock', 'uniqueKey': 'unlock-test'}) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Lock the request + lock_response = await rq_client.list_and_lock_head(limit=1, lock_secs=60) + assert len(lock_response.items) == 1 + locked_request = lock_response.items[0] + + # Delete the lock + await rq_client.delete_request_lock(locked_request.id) + + # Verify the operation succeeded (no exception thrown) + # The request should still exist but be unlocked + request = await rq_client.get_request(locked_request.id) + assert request is not None + + # Cleanup + await rq_client.delete() + + +async def test_request_queue_unlock_requests(apify_client_async: ApifyClientAsync) -> None: + """Test unlocking all requests locked by the client.""" + rq_name = get_random_resource_name('queue') + + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + rq_client = apify_client_async.request_queue(created_rq.id, client_key=get_random_string(10)) + + # Add multiple requests + for i in range(5): + await rq_client.add_request({'url': f'https://example.com/unlock-{i}', 'uniqueKey': f'unlock-{i}'}) + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Lock some requests + lock_response = await rq_client.list_and_lock_head(limit=3, lock_secs=60) + assert len(lock_response.items) == 3 + + # Unlock all requests + unlock_response = await rq_client.unlock_requests() + + assert unlock_response is not None + assert unlock_response.unlocked_count == 3 + + # Cleanup + await rq_client.delete() + + +async def test_request_queue_update_request(apify_client_async: ApifyClientAsync) -> None: + """Test updating a request in the queue.""" + rq_name = get_random_resource_name('queue') + + created_rq = await apify_client_async.request_queues().get_or_create(name=rq_name) + rq_client = apify_client_async.request_queue(created_rq.id) + + # Add a request + request_data = { + 'url': 'https://example.com/original', + 'uniqueKey': 'update-test', + 'method': 'GET', + } + add_result = await rq_client.add_request(request_data) + assert add_result is not None + assert add_result.request_id is not None + + # Wait briefly for eventual consistency + await asyncio.sleep(1) + + # Get the request to get its full data + original_request = await rq_client.get_request(add_result.request_id) + assert original_request is not None + + # Update the request (change method and add user data) + updated_request_data = { + 'id': add_result.request_id, + 'url': str(original_request.url), + 'uniqueKey': original_request.unique_key, + 'method': 'POST', + 'userData': {'updated': True}, + } + update_result = await rq_client.update_request(updated_request_data) + assert update_result is not None + assert update_result.request_id == add_result.request_id + + # Cleanup + await rq_client.delete() diff --git a/tests/integration/test_request_queue_collection.py b/tests/integration/test_request_queue_collection.py new file mode 100644 index 00000000..e6bc3f91 --- /dev/null +++ b/tests/integration/test_request_queue_collection.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apify_client import ApifyClient + + +def test_request_queues_list(apify_client: ApifyClient) -> None: + """Test listing request queues.""" + rq_page = apify_client.request_queues().list(limit=10) + + assert rq_page is not None + assert rq_page.items is not None + assert isinstance(rq_page.items, list) + + +def test_request_queues_list_pagination(apify_client: ApifyClient) -> None: + """Test listing request queues with pagination.""" + rq_page = apify_client.request_queues().list(limit=5, offset=0) + + assert rq_page is not None + assert rq_page.items is not None + assert isinstance(rq_page.items, list) + + +def test_request_queues_get_or_create(apify_client: ApifyClient) -> None: + """Test get_or_create for request queues.""" + unique_name = f'test-rq-{uuid.uuid4().hex[:8]}' + + # Create new RQ + rq = apify_client.request_queues().get_or_create(name=unique_name) + assert rq is not None + assert rq.name == unique_name + + # Get same RQ again (should return existing) + same_rq = apify_client.request_queues().get_or_create(name=unique_name) + assert same_rq.id == rq.id + + # Cleanup + apify_client.request_queue(rq.id).delete() diff --git a/tests/integration/test_request_queue_collection_async.py b/tests/integration/test_request_queue_collection_async.py new file mode 100644 index 00000000..3353ca52 --- /dev/null +++ b/tests/integration/test_request_queue_collection_async.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + + +async def test_request_queues_list(apify_client_async: ApifyClientAsync) -> None: + """Test listing request queues.""" + rq_page = await apify_client_async.request_queues().list(limit=10) + + assert rq_page is not None + assert rq_page.items is not None + assert isinstance(rq_page.items, list) + + +async def test_request_queues_list_pagination(apify_client_async: ApifyClientAsync) -> None: + """Test listing request queues with pagination.""" + rq_page = await apify_client_async.request_queues().list(limit=5, offset=0) + + assert rq_page is not None + assert rq_page.items is not None + assert isinstance(rq_page.items, list) + + +async def test_request_queues_get_or_create(apify_client_async: ApifyClientAsync) -> None: + """Test get_or_create for request queues.""" + unique_name = f'test-rq-{uuid.uuid4().hex[:8]}' + + # Create new RQ + rq = await apify_client_async.request_queues().get_or_create(name=unique_name) + assert rq is not None + assert rq.name == unique_name + + # Get same RQ again (should return existing) + same_rq = await apify_client_async.request_queues().get_or_create(name=unique_name) + assert same_rq.id == rq.id + + # Cleanup + await apify_client_async.request_queue(rq.id).delete() diff --git a/tests/integration/test_run.py b/tests/integration/test_run.py new file mode 100644 index 00000000..46100211 --- /dev/null +++ b/tests/integration/test_run.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import time +from typing import TYPE_CHECKING + +from apify_client.errors import ApifyApiError + +if TYPE_CHECKING: + from apify_client import ApifyClient + +HELLO_WORLD_ACTOR = 'apify/hello-world' + + +def test_run_get_and_delete(apify_client: ApifyClient) -> None: + """Test getting and deleting a run.""" + # Run actor + actor = apify_client.actor(HELLO_WORLD_ACTOR) + run = actor.call() + assert run is not None + + # Get the run + run_client = apify_client.run(run.id) + retrieved_run = run_client.get() + assert retrieved_run is not None + assert retrieved_run.id == run.id + assert retrieved_run.status.value == 'SUCCEEDED' + + # Delete the run + run_client.delete() + + # Verify it's gone + deleted_run = run_client.get() + assert deleted_run is None + + +def test_run_dataset(apify_client: ApifyClient) -> None: + """Test accessing run's default dataset.""" + # Run actor + actor = apify_client.actor(HELLO_WORLD_ACTOR) + run = actor.call() + assert run is not None + + # Access run's dataset + run_client = apify_client.run(run.id) + dataset_client = run_client.dataset() + + # Get dataset info + dataset = dataset_client.get() + assert dataset is not None + assert dataset.id == run.default_dataset_id + + # Cleanup + run_client.delete() + + +def test_run_key_value_store(apify_client: ApifyClient) -> None: + """Test accessing run's default key-value store.""" + # Run actor + actor = apify_client.actor(HELLO_WORLD_ACTOR) + run = actor.call() + assert run is not None + + # Access run's key-value store + run_client = apify_client.run(run.id) + kvs_client = run_client.key_value_store() + + # Get KVS info + kvs = kvs_client.get() + assert kvs is not None + assert kvs.id == run.default_key_value_store_id + + # Cleanup + run_client.delete() + + +def test_run_request_queue(apify_client: ApifyClient) -> None: + """Test accessing run's default request queue.""" + # Run actor + actor = apify_client.actor(HELLO_WORLD_ACTOR) + run = actor.call() + assert run is not None + + # Access run's request queue + run_client = apify_client.run(run.id) + rq_client = run_client.request_queue() + + # Get RQ info + rq = rq_client.get() + assert rq is not None + assert rq.id == run.default_request_queue_id + + # Cleanup + run_client.delete() + + +def test_run_abort(apify_client: ApifyClient) -> None: + """Test aborting a running actor.""" + # Start actor without waiting + actor = apify_client.actor(HELLO_WORLD_ACTOR) + run = actor.start() + assert run is not None + assert run.id is not None + + # Abort the run + run_client = apify_client.run(run.id) + aborted_run = run_client.abort() + + assert aborted_run is not None + # Status should be ABORTING or ABORTED (or SUCCEEDED if too fast) + assert aborted_run.status.value in ['ABORTING', 'ABORTED', 'SUCCEEDED'] + + # Wait for abort to complete + final_run = run_client.wait_for_finish() + assert final_run is not None + assert final_run.status.value in ['ABORTED', 'SUCCEEDED'] + + # Cleanup + run_client.delete() + + +def test_run_update(apify_client: ApifyClient) -> None: + """Test updating a run's status message.""" + # Run actor + actor = apify_client.actor(HELLO_WORLD_ACTOR) + run = actor.call() + assert run is not None + + run_client = apify_client.run(run.id) + + try: + # Update run status message + updated_run = run_client.update( + status_message='Test status message', + is_status_message_terminal=True, + ) + assert updated_run is not None + assert updated_run.status_message == 'Test status message' + + finally: + # Cleanup + run_client.delete() + + +def test_run_resurrect(apify_client: ApifyClient) -> None: + """Test resurrecting a finished run.""" + # Run actor and wait for it to finish + actor = apify_client.actor(HELLO_WORLD_ACTOR) + run = actor.call() + assert run is not None + assert run.status.value == 'SUCCEEDED' + + run_client = apify_client.run(run.id) + + try: + # Resurrect the run + resurrected_run = run_client.resurrect() + assert resurrected_run is not None + # Status should be READY, RUNNING or already finished (if fast) + assert resurrected_run.status.value in ['READY', 'RUNNING', 'SUCCEEDED'] + + # Wait for it to finish before deleting + final_run = run_client.wait_for_finish() + assert final_run is not None + assert final_run.status.value == 'SUCCEEDED' + + finally: + # Wait for run to finish before cleanup (resurrected run might still be running) + run_client.wait_for_finish() + run_client.delete() + + +def test_run_log(apify_client: ApifyClient) -> None: + """Test accessing run's log.""" + # Run actor + actor = apify_client.actor(HELLO_WORLD_ACTOR) + run = actor.call() + assert run is not None + + run_client = apify_client.run(run.id) + + try: + # Get log client + log_client = run_client.log() + + # Get log content + log_content = log_client.get() + assert log_content is not None + # Log should contain something (at least actor startup messages) + assert len(log_content) > 0 + + finally: + # Cleanup + run_client.delete() + + +def test_run_runs_client(apify_client: ApifyClient) -> None: + """Test listing runs through the run collection client.""" + # List runs (should return valid data structure) + runs_page = apify_client.runs().list(limit=10) + assert runs_page is not None + assert runs_page.items is not None + assert isinstance(runs_page.items, list) + # The user may have runs, verify the structure + if runs_page.items: + first_run = runs_page.items[0] + assert first_run.id is not None + assert first_run.act_id is not None + + +def test_run_metamorph(apify_client: ApifyClient) -> None: + """Test metamorphing a run into another actor.""" + # Start an actor that will run long enough to metamorph. We use hello-world and try to metamorph it into itself + actor = apify_client.actor(HELLO_WORLD_ACTOR) + run = actor.start() + assert run is not None + assert run.id is not None + + run_client = apify_client.run(run.id) + + try: + # Wait a bit for the run to start properly + time.sleep(2) + + # Metamorph the run into the same actor (allowed) with new input + # Note: hello-world may finish before we can metamorph, so we handle that case + try: + metamorphed_run = run_client.metamorph( + target_actor_id=HELLO_WORLD_ACTOR, + run_input={'message': 'Hello from metamorph!'}, + ) + assert metamorphed_run is not None + assert metamorphed_run.id == run.id # Same run ID + + # Wait for the metamorphed run to finish + final_run = run_client.wait_for_finish() + assert final_run is not None + except ApifyApiError as exc: + # If the actor finished before we could metamorph, that's OK - the test still verified the API call + if 'already finished' not in str(exc): + raise + + finally: + # Cleanup + run_client.wait_for_finish() + run_client.delete() + + +def test_run_reboot(apify_client: ApifyClient) -> None: + """Test rebooting a running actor.""" + # Start an actor + actor = apify_client.actor(HELLO_WORLD_ACTOR) + run = actor.start() + assert run is not None + assert run.id is not None + + run_client = apify_client.run(run.id) + + try: + # Wait a bit and check if the run is still running + time.sleep(1) + current_run = run_client.get() + + # Only try to reboot if the run is still running + # Note: There's a race condition - run may finish between check and reboot call + if current_run and current_run.status.value == 'RUNNING': + try: + rebooted_run = run_client.reboot() + assert rebooted_run is not None + assert rebooted_run.id == run.id + except ApifyApiError as exc: + # If the actor finished before we could reboot, that's OK + if 'already finished' not in str(exc): + raise + + # Wait for the run to finish + final_run = run_client.wait_for_finish() + assert final_run is not None + + finally: + # Cleanup + run_client.wait_for_finish() + run_client.delete() + + +def test_run_charge(apify_client: ApifyClient) -> None: + """Test charging for an event in a pay-per-event run. + + Note: This test may fail if the actor is not a pay-per-event actor. The test verifies that the charge method can + be called correctly. + """ + # Run an actor + actor = apify_client.actor(HELLO_WORLD_ACTOR) + run = actor.call() + assert run is not None + + run_client = apify_client.run(run.id) + + try: + # Try to charge - this will fail for non-PPE actors but tests the API call + try: + run_client.charge(event_name='test-event', count=1) + # If it succeeds, the actor supports PPE + except ApifyApiError as exc: + # Expected error for non-PPE actors - re-raise if unexpected. + # The API returns an error indicating this is not a PPE run. + if exc.status_code not in [400, 403, 404]: + raise + + finally: + # Cleanup + run_client.delete() diff --git a/tests/integration/test_run_async.py b/tests/integration/test_run_async.py new file mode 100644 index 00000000..afa713f9 --- /dev/null +++ b/tests/integration/test_run_async.py @@ -0,0 +1,311 @@ +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from apify_client.errors import ApifyApiError + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + +HELLO_WORLD_ACTOR = 'apify/hello-world' + + +async def test_run_get_and_delete(apify_client_async: ApifyClientAsync) -> None: + """Test getting and deleting a run.""" + # Run actor + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + run = await actor.call() + assert run is not None + + # Get the run + run_client = apify_client_async.run(run.id) + retrieved_run = await run_client.get() + assert retrieved_run is not None + assert retrieved_run.id == run.id + assert retrieved_run.status.value == 'SUCCEEDED' + + # Delete the run + await run_client.delete() + + # Verify it's gone + deleted_run = await run_client.get() + assert deleted_run is None + + +async def test_run_dataset(apify_client_async: ApifyClientAsync) -> None: + """Test accessing run's default dataset.""" + # Run actor + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + run = await actor.call() + assert run is not None + + # Access run's dataset + run_client = apify_client_async.run(run.id) + dataset_client = run_client.dataset() + + # Get dataset info + dataset = await dataset_client.get() + assert dataset is not None + assert dataset.id == run.default_dataset_id + + # Cleanup + await run_client.delete() + + +async def test_run_key_value_store(apify_client_async: ApifyClientAsync) -> None: + """Test accessing run's default key-value store.""" + # Run actor + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + run = await actor.call() + assert run is not None + + # Access run's key-value store + run_client = apify_client_async.run(run.id) + kvs_client = run_client.key_value_store() + + # Get KVS info + kvs = await kvs_client.get() + assert kvs is not None + assert kvs.id == run.default_key_value_store_id + + # Cleanup + await run_client.delete() + + +async def test_run_request_queue(apify_client_async: ApifyClientAsync) -> None: + """Test accessing run's default request queue.""" + # Run actor + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + run = await actor.call() + assert run is not None + + # Access run's request queue + run_client = apify_client_async.run(run.id) + rq_client = run_client.request_queue() + + # Get RQ info + rq = await rq_client.get() + assert rq is not None + assert rq.id == run.default_request_queue_id + + # Cleanup + await run_client.delete() + + +async def test_run_abort(apify_client_async: ApifyClientAsync) -> None: + """Test aborting a running actor.""" + # Start actor without waiting + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + run = await actor.start() + assert run is not None + assert run.id is not None + + # Abort the run + run_client = apify_client_async.run(run.id) + aborted_run = await run_client.abort() + + assert aborted_run is not None + # Status should be ABORTING or ABORTED (or SUCCEEDED if too fast) + assert aborted_run.status.value in ['ABORTING', 'ABORTED', 'SUCCEEDED'] + + # Wait for abort to complete + final_run = await run_client.wait_for_finish() + assert final_run is not None + assert final_run.status.value in ['ABORTED', 'SUCCEEDED'] + + # Cleanup + await run_client.delete() + + +async def test_run_update(apify_client_async: ApifyClientAsync) -> None: + """Test updating a run's status message.""" + # Run actor + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + run = await actor.call() + assert run is not None + + run_client = apify_client_async.run(run.id) + + try: + # Update run status message + updated_run = await run_client.update( + status_message='Test status message', + is_status_message_terminal=True, + ) + assert updated_run is not None + assert updated_run.status_message == 'Test status message' + + finally: + # Cleanup + await run_client.delete() + + +async def test_run_resurrect(apify_client_async: ApifyClientAsync) -> None: + """Test resurrecting a finished run.""" + # Run actor and wait for it to finish + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + run = await actor.call() + assert run is not None + assert run.status.value == 'SUCCEEDED' + + run_client = apify_client_async.run(run.id) + + try: + # Resurrect the run + resurrected_run = await run_client.resurrect() + assert resurrected_run is not None + # Status should be READY, RUNNING or already finished (if fast) + assert resurrected_run.status.value in ['READY', 'RUNNING', 'SUCCEEDED'] + + # Wait for it to finish before deleting + final_run = await run_client.wait_for_finish() + assert final_run is not None + assert final_run.status.value == 'SUCCEEDED' + + finally: + # Wait for run to finish before cleanup (resurrected run might still be running) + await run_client.wait_for_finish() + await run_client.delete() + + +async def test_run_log(apify_client_async: ApifyClientAsync) -> None: + """Test accessing run's log.""" + # Run actor + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + run = await actor.call() + assert run is not None + + run_client = apify_client_async.run(run.id) + + try: + # Get log client + log_client = run_client.log() + + # Get log content + log_content = await log_client.get() + assert log_content is not None + # Log should contain something (at least actor startup messages) + assert len(log_content) > 0 + + finally: + # Cleanup + await run_client.delete() + + +async def test_run_runs_client(apify_client_async: ApifyClientAsync) -> None: + """Test listing runs through the run collection client.""" + # List runs (should return valid data structure) + runs_page = await apify_client_async.runs().list(limit=10) + assert runs_page is not None + assert runs_page.items is not None + assert isinstance(runs_page.items, list) + # The user may have runs, verify the structure + if runs_page.items: + first_run = runs_page.items[0] + assert first_run.id is not None + assert first_run.act_id is not None + + +async def test_run_metamorph(apify_client_async: ApifyClientAsync) -> None: + """Test metamorphing a run into another actor.""" + # Start an actor that will run long enough to metamorph. We use hello-world and try to metamorph it into itself + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + run = await actor.start() + assert run is not None + assert run.id is not None + + run_client = apify_client_async.run(run.id) + + try: + # Wait a bit for the run to start properly + await asyncio.sleep(2) + + # Metamorph the run into the same actor (allowed) with new input + # Note: hello-world may finish before we can metamorph, so we handle that case + try: + metamorphed_run = await run_client.metamorph( + target_actor_id=HELLO_WORLD_ACTOR, + run_input={'message': 'Hello from metamorph!'}, + ) + assert metamorphed_run is not None + assert metamorphed_run.id == run.id # Same run ID + + # Wait for the metamorphed run to finish + final_run = await run_client.wait_for_finish() + assert final_run is not None + except ApifyApiError as exc: + # If the actor finished before we could metamorph, that's OK - the test still verified the API call + if 'already finished' not in str(exc): + raise + + finally: + # Cleanup + await run_client.wait_for_finish() + await run_client.delete() + + +async def test_run_reboot(apify_client_async: ApifyClientAsync) -> None: + """Test rebooting a running actor.""" + # Start an actor + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + run = await actor.start() + assert run is not None + assert run.id is not None + + run_client = apify_client_async.run(run.id) + + try: + # Wait a bit and check if the run is still running + await asyncio.sleep(1) + current_run = await run_client.get() + + # Only try to reboot if the run is still running + # Note: There's a race condition - run may finish between check and reboot call + if current_run and current_run.status.value == 'RUNNING': + try: + rebooted_run = await run_client.reboot() + assert rebooted_run is not None + assert rebooted_run.id == run.id + except ApifyApiError as exc: + # If the actor finished before we could reboot, that's OK + if 'already finished' not in str(exc): + raise + + # Wait for the run to finish + final_run = await run_client.wait_for_finish() + assert final_run is not None + + finally: + # Cleanup + await run_client.wait_for_finish() + await run_client.delete() + + +async def test_run_charge(apify_client_async: ApifyClientAsync) -> None: + """Test charging for an event in a pay-per-event run. + + Note: This test may fail if the actor is not a pay-per-event actor. The test verifies that the charge method can + be called correctly. + """ + # Run an actor + actor = apify_client_async.actor(HELLO_WORLD_ACTOR) + run = await actor.call() + assert run is not None + + run_client = apify_client_async.run(run.id) + + try: + # Try to charge - this will fail for non-PPE actors but tests the API call + try: + await run_client.charge(event_name='test-event', count=1) + # If it succeeds, the actor supports PPE + except ApifyApiError as exc: + # Expected error for non-PPE actors - re-raise if unexpected. + # The API returns an error indicating this is not a PPE run. + if exc.status_code not in [400, 403, 404]: + raise + + finally: + # Cleanup + await run_client.delete() diff --git a/tests/integration/test_run_collection.py b/tests/integration/test_run_collection.py index bfff83cf..1afe6047 100644 --- a/tests/integration/test_run_collection.py +++ b/tests/integration/test_run_collection.py @@ -3,73 +3,69 @@ from datetime import datetime, timezone from typing import TYPE_CHECKING -import pytest +from apify_client._models import ActorJobStatus, Run if TYPE_CHECKING: from apify_client import ApifyClient -from apify_shared.consts import ActorJobStatus +APIFY_HELLO_WORLD_ACTOR = 'apify/hello-world' -pytestmark = pytest.mark.integration +def test_run_collection_list_multiple_statuses(apify_client: ApifyClient) -> None: + created_runs = list[Run]() -class TestRunCollectionSync: - APIFY_HELLO_WORLD_ACTOR = 'apify/hello-world' - created_runs: list[dict] + successful_run = apify_client.actor(APIFY_HELLO_WORLD_ACTOR).call() + if successful_run is not None: + created_runs.append(successful_run) - def setup_runs(self, apify_client: ApifyClient) -> None: - self.created_runs = [] + timed_out_run = apify_client.actor(APIFY_HELLO_WORLD_ACTOR).call(timeout_secs=1) + if timed_out_run is not None: + created_runs.append(timed_out_run) - successful_run = apify_client.actor(self.APIFY_HELLO_WORLD_ACTOR).call() - if successful_run is not None: - self.created_runs.append(successful_run) + run_collection = apify_client.actor(APIFY_HELLO_WORLD_ACTOR).runs() - timed_out_run = apify_client.actor(self.APIFY_HELLO_WORLD_ACTOR).call(timeout_secs=1) - if timed_out_run is not None: - self.created_runs.append(timed_out_run) + multiple_status_runs = run_collection.list(status=[ActorJobStatus.SUCCEEDED, ActorJobStatus.TIMED_OUT]) + single_status_runs = run_collection.list(status=ActorJobStatus.SUCCEEDED) - def teadown_runs(self, apify_client: ApifyClient) -> None: - for run in self.created_runs: - run_id = run.get('id') - if isinstance(run_id, str): - apify_client.run(run_id).delete() + assert multiple_status_runs is not None + assert single_status_runs is not None - async def test_run_collection_list_multiple_statuses(self, apify_client: ApifyClient) -> None: - self.setup_runs(apify_client) + assert hasattr(multiple_status_runs, 'items') + assert hasattr(single_status_runs, 'items') - run_collection = apify_client.actor(self.APIFY_HELLO_WORLD_ACTOR).runs() + assert all(run.status in [ActorJobStatus.SUCCEEDED, ActorJobStatus.TIMED_OUT] for run in multiple_status_runs.items) + assert all(run.status == ActorJobStatus.SUCCEEDED for run in single_status_runs.items) - multiple_status_runs = run_collection.list(status=[ActorJobStatus.SUCCEEDED, ActorJobStatus.TIMED_OUT]) - single_status_runs = run_collection.list(status=ActorJobStatus.SUCCEEDED) + for run in created_runs: + run_id = run.id + if isinstance(run_id, str): + apify_client.run(run_id).delete() - assert multiple_status_runs is not None - assert single_status_runs is not None - assert hasattr(multiple_status_runs, 'items') - assert hasattr(single_status_runs, 'items') +def test_run_collection_list_accept_date_range(apify_client: ApifyClient) -> None: + created_runs = list[Run]() - assert all( - run.get('status') in [ActorJobStatus.SUCCEEDED, ActorJobStatus.TIMED_OUT] - for run in multiple_status_runs.items - ) - assert all(run.get('status') == ActorJobStatus.SUCCEEDED for run in single_status_runs.items) + successful_run = apify_client.actor(APIFY_HELLO_WORLD_ACTOR).call() + if successful_run is not None: + created_runs.append(successful_run) - self.teadown_runs(apify_client) + timed_out_run = apify_client.actor(APIFY_HELLO_WORLD_ACTOR).call(timeout_secs=1) + if timed_out_run is not None: + created_runs.append(timed_out_run) - # Here we test that date fields can be passed both as datetime objects and as ISO 8601 strings - async def test_run_collection_list_accept_date_range(self, apify_client: ApifyClient) -> None: - self.setup_runs(apify_client) - - run_collection = apify_client.runs() + run_collection = apify_client.runs() - date_obj = datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc) - iso_date_str = date_obj.strftime('%Y-%m-%dT%H:%M:%SZ') + date_obj = datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + iso_date_str = date_obj.strftime('%Y-%m-%dT%H:%M:%SZ') - # Here we test that date fields can be passed both as datetime objects and as ISO 8601 strings - runs_in_range_date_format = run_collection.list(started_before=date_obj, started_after=date_obj) - runs_in_range_string_format = run_collection.list(started_before=iso_date_str, started_after=iso_date_str) + # Here we test that date fields can be passed both as datetime objects and as ISO 8601 strings + runs_in_range_date_format = run_collection.list(started_before=date_obj, started_after=date_obj) + runs_in_range_string_format = run_collection.list(started_before=iso_date_str, started_after=iso_date_str) - assert hasattr(runs_in_range_date_format, 'items') - assert hasattr(runs_in_range_string_format, 'items') + assert hasattr(runs_in_range_date_format, 'items') + assert hasattr(runs_in_range_string_format, 'items') - self.teadown_runs(apify_client) + for run in created_runs: + run_id = run.id + if isinstance(run_id, str): + apify_client.run(run_id).delete() diff --git a/tests/integration/test_run_collection_async.py b/tests/integration/test_run_collection_async.py new file mode 100644 index 00000000..eb4a8d39 --- /dev/null +++ b/tests/integration/test_run_collection_async.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import TYPE_CHECKING + +from apify_client._models import ActorJobStatus, Run + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + +APIFY_HELLO_WORLD_ACTOR = 'apify/hello-world' + + +async def test_run_collection_list_multiple_statuses(apify_client_async: ApifyClientAsync) -> None: + created_runs = list[Run]() + + successful_run = await apify_client_async.actor(APIFY_HELLO_WORLD_ACTOR).call() + if successful_run is not None: + created_runs.append(successful_run) + + timed_out_run = await apify_client_async.actor(APIFY_HELLO_WORLD_ACTOR).call(timeout_secs=1) + if timed_out_run is not None: + created_runs.append(timed_out_run) + + run_collection = apify_client_async.actor(APIFY_HELLO_WORLD_ACTOR).runs() + + multiple_status_runs = await run_collection.list(status=[ActorJobStatus.SUCCEEDED, ActorJobStatus.TIMED_OUT]) + single_status_runs = await run_collection.list(status=ActorJobStatus.SUCCEEDED) + + assert multiple_status_runs is not None + assert single_status_runs is not None + + assert hasattr(multiple_status_runs, 'items') + assert hasattr(single_status_runs, 'items') + + assert all(run.status in [ActorJobStatus.SUCCEEDED, ActorJobStatus.TIMED_OUT] for run in multiple_status_runs.items) + assert all(run.status == ActorJobStatus.SUCCEEDED for run in single_status_runs.items) + + for run in created_runs: + run_id = run.id + if isinstance(run_id, str): + await apify_client_async.run(run_id).delete() + + +async def test_run_collection_list_accept_date_range(apify_client_async: ApifyClientAsync) -> None: + created_runs = list[Run]() + + successful_run = await apify_client_async.actor(APIFY_HELLO_WORLD_ACTOR).call() + if successful_run is not None: + created_runs.append(successful_run) + + timed_out_run = await apify_client_async.actor(APIFY_HELLO_WORLD_ACTOR).call(timeout_secs=1) + if timed_out_run is not None: + created_runs.append(timed_out_run) + + run_collection = apify_client_async.runs() + + date_obj = datetime(2100, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + iso_date_str = date_obj.strftime('%Y-%m-%dT%H:%M:%SZ') + + # Here we test that date fields can be passed both as datetime objects and as ISO 8601 strings + runs_in_range_date_format = await run_collection.list(started_before=date_obj, started_after=date_obj) + runs_in_range_string_format = await run_collection.list(started_before=iso_date_str, started_after=iso_date_str) + + assert hasattr(runs_in_range_date_format, 'items') + assert hasattr(runs_in_range_string_format, 'items') + + for run in created_runs: + run_id = run.id + if isinstance(run_id, str): + await apify_client_async.run(run_id).delete() diff --git a/tests/integration/test_schedule.py b/tests/integration/test_schedule.py new file mode 100644 index 00000000..3377a5db --- /dev/null +++ b/tests/integration/test_schedule.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .utils import get_random_resource_name + +if TYPE_CHECKING: + from apify_client import ApifyClient + + +def test_schedule_create_and_get(apify_client: ApifyClient) -> None: + """Test creating a schedule and retrieving it.""" + schedule_name = get_random_resource_name('schedule') + + # Create schedule + created_schedule = apify_client.schedules().create( + cron_expression='0 0 * * *', + is_enabled=False, + is_exclusive=False, + name=schedule_name, + ) + assert created_schedule is not None + assert created_schedule.id is not None + assert created_schedule.name == schedule_name + assert created_schedule.cron_expression == '0 0 * * *' + assert created_schedule.is_enabled is False + assert created_schedule.is_exclusive is False + + # Get the same schedule + schedule_client = apify_client.schedule(created_schedule.id) + retrieved_schedule = schedule_client.get() + assert retrieved_schedule is not None + assert retrieved_schedule.id == created_schedule.id + assert retrieved_schedule.name == schedule_name + + # Cleanup + schedule_client.delete() + + +def test_schedule_update(apify_client: ApifyClient) -> None: + """Test updating schedule properties.""" + schedule_name = get_random_resource_name('schedule') + new_name = get_random_resource_name('schedule-updated') + + # Create schedule + created_schedule = apify_client.schedules().create( + cron_expression='0 0 * * *', + is_enabled=False, + is_exclusive=False, + name=schedule_name, + ) + schedule_client = apify_client.schedule(created_schedule.id) + + # Update the schedule + updated_schedule = schedule_client.update( + name=new_name, + cron_expression='0 12 * * *', + is_enabled=True, + ) + assert updated_schedule is not None + assert updated_schedule.name == new_name + assert updated_schedule.cron_expression == '0 12 * * *' + assert updated_schedule.is_enabled is True + assert updated_schedule.id == created_schedule.id + + # Verify the update persisted + retrieved_schedule = schedule_client.get() + assert retrieved_schedule is not None + assert retrieved_schedule.name == new_name + assert retrieved_schedule.cron_expression == '0 12 * * *' + + # Cleanup + schedule_client.delete() + + +def test_schedule_list(apify_client: ApifyClient) -> None: + """Test listing schedules.""" + schedule_name_1 = get_random_resource_name('schedule') + schedule_name_2 = get_random_resource_name('schedule') + + # Create two schedules + created_1 = apify_client.schedules().create( + cron_expression='0 0 * * *', + is_enabled=False, + is_exclusive=False, + name=schedule_name_1, + ) + created_2 = apify_client.schedules().create( + cron_expression='0 6 * * *', + is_enabled=False, + is_exclusive=False, + name=schedule_name_2, + ) + + # List schedules + schedules_page = apify_client.schedules().list(limit=100) + assert schedules_page is not None + assert schedules_page.items is not None + + # Verify our schedules are in the list + schedule_ids = [s.id for s in schedules_page.items] + assert created_1.id in schedule_ids + assert created_2.id in schedule_ids + + # Cleanup + apify_client.schedule(created_1.id).delete() + apify_client.schedule(created_2.id).delete() + + +def test_schedule_delete(apify_client: ApifyClient) -> None: + """Test deleting a schedule.""" + schedule_name = get_random_resource_name('schedule') + + # Create schedule + created_schedule = apify_client.schedules().create( + cron_expression='0 0 * * *', + is_enabled=False, + is_exclusive=False, + name=schedule_name, + ) + schedule_client = apify_client.schedule(created_schedule.id) + + # Delete schedule + schedule_client.delete() + + # Verify it's gone + retrieved_schedule = schedule_client.get() + assert retrieved_schedule is None + + +def test_schedule_get_log(apify_client: ApifyClient) -> None: + """Test getting schedule log.""" + schedule_name = get_random_resource_name('schedule') + + # Create schedule + created_schedule = apify_client.schedules().create( + cron_expression='0 0 * * *', + is_enabled=False, + is_exclusive=False, + name=schedule_name, + ) + schedule_client = apify_client.schedule(created_schedule.id) + + try: + # Get schedule log - new schedule has no log entries but the method should work + log = schedule_client.get_log() + + # Log should be None or empty list for a new disabled schedule + assert log is None or isinstance(log, list) + + finally: + # Cleanup + schedule_client.delete() diff --git a/tests/integration/test_schedule_async.py b/tests/integration/test_schedule_async.py new file mode 100644 index 00000000..bf3c55fe --- /dev/null +++ b/tests/integration/test_schedule_async.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .utils import get_random_resource_name + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + + +async def test_schedule_create_and_get(apify_client_async: ApifyClientAsync) -> None: + """Test creating a schedule and retrieving it.""" + schedule_name = get_random_resource_name('schedule') + + # Create schedule + created_schedule = await apify_client_async.schedules().create( + cron_expression='0 0 * * *', + is_enabled=False, + is_exclusive=False, + name=schedule_name, + ) + assert created_schedule is not None + assert created_schedule.id is not None + assert created_schedule.name == schedule_name + assert created_schedule.cron_expression == '0 0 * * *' + assert created_schedule.is_enabled is False + assert created_schedule.is_exclusive is False + + # Get the same schedule + schedule_client = apify_client_async.schedule(created_schedule.id) + retrieved_schedule = await schedule_client.get() + assert retrieved_schedule is not None + assert retrieved_schedule.id == created_schedule.id + assert retrieved_schedule.name == schedule_name + + # Cleanup + await schedule_client.delete() + + +async def test_schedule_update(apify_client_async: ApifyClientAsync) -> None: + """Test updating schedule properties.""" + schedule_name = get_random_resource_name('schedule') + new_name = get_random_resource_name('schedule-updated') + + # Create schedule + created_schedule = await apify_client_async.schedules().create( + cron_expression='0 0 * * *', + is_enabled=False, + is_exclusive=False, + name=schedule_name, + ) + schedule_client = apify_client_async.schedule(created_schedule.id) + + # Update the schedule + updated_schedule = await schedule_client.update( + name=new_name, + cron_expression='0 12 * * *', + is_enabled=True, + ) + assert updated_schedule is not None + assert updated_schedule.name == new_name + assert updated_schedule.cron_expression == '0 12 * * *' + assert updated_schedule.is_enabled is True + assert updated_schedule.id == created_schedule.id + + # Verify the update persisted + retrieved_schedule = await schedule_client.get() + assert retrieved_schedule is not None + assert retrieved_schedule.name == new_name + assert retrieved_schedule.cron_expression == '0 12 * * *' + + # Cleanup + await schedule_client.delete() + + +async def test_schedule_list(apify_client_async: ApifyClientAsync) -> None: + """Test listing schedules.""" + schedule_name_1 = get_random_resource_name('schedule') + schedule_name_2 = get_random_resource_name('schedule') + + # Create two schedules + created_1 = await apify_client_async.schedules().create( + cron_expression='0 0 * * *', + is_enabled=False, + is_exclusive=False, + name=schedule_name_1, + ) + created_2 = await apify_client_async.schedules().create( + cron_expression='0 6 * * *', + is_enabled=False, + is_exclusive=False, + name=schedule_name_2, + ) + + # List schedules + schedules_page = await apify_client_async.schedules().list(limit=100) + assert schedules_page is not None + assert schedules_page.items is not None + + # Verify our schedules are in the list + schedule_ids = [s.id for s in schedules_page.items] + assert created_1.id in schedule_ids + assert created_2.id in schedule_ids + + # Cleanup + await apify_client_async.schedule(created_1.id).delete() + await apify_client_async.schedule(created_2.id).delete() + + +async def test_schedule_delete(apify_client_async: ApifyClientAsync) -> None: + """Test deleting a schedule.""" + schedule_name = get_random_resource_name('schedule') + + # Create schedule + created_schedule = await apify_client_async.schedules().create( + cron_expression='0 0 * * *', + is_enabled=False, + is_exclusive=False, + name=schedule_name, + ) + schedule_client = apify_client_async.schedule(created_schedule.id) + + # Delete schedule + await schedule_client.delete() + + # Verify it's gone + retrieved_schedule = await schedule_client.get() + assert retrieved_schedule is None + + +async def test_schedule_get_log(apify_client_async: ApifyClientAsync) -> None: + """Test getting schedule log.""" + schedule_name = get_random_resource_name('schedule') + + # Create schedule + created_schedule = await apify_client_async.schedules().create( + cron_expression='0 0 * * *', + is_enabled=False, + is_exclusive=False, + name=schedule_name, + ) + schedule_client = apify_client_async.schedule(created_schedule.id) + + try: + # Get schedule log - new schedule has no log entries but the method should work + log = await schedule_client.get_log() + + # Log should be None or empty list for a new disabled schedule + assert log is None or isinstance(log, list) + + finally: + # Cleanup + await schedule_client.delete() diff --git a/tests/integration/test_store.py b/tests/integration/test_store.py index fa2ce27b..24fb3f73 100644 --- a/tests/integration/test_store.py +++ b/tests/integration/test_store.py @@ -3,18 +3,33 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from apify_client import ApifyClient, ApifyClientAsync + from apify_client import ApifyClient -class TestStoreCollectionSync: - def test_list(self, apify_client: ApifyClient) -> None: - actors_list = apify_client.store().list() - assert actors_list is not None - assert len(actors_list.items) != 0 +def test_store_list(apify_client: ApifyClient) -> None: + """Test listing public actors in the store.""" + actors_list = apify_client.store().list(limit=10) + assert actors_list is not None + assert actors_list.items is not None + assert len(actors_list.items) > 0 # Store always has actors -class TestStoreCollectionAsync: - async def test_list(self, apify_client_async: ApifyClientAsync) -> None: - actors_list = await apify_client_async.store().list() - assert actors_list is not None - assert len(actors_list.items) != 0 +def test_store_list_with_search(apify_client: ApifyClient) -> None: + """Test listing store with search filter.""" + store_page = apify_client.store().list(limit=5, search='web scraper') + + assert store_page is not None + assert store_page.items is not None + assert isinstance(store_page.items, list) + + +def test_store_list_pagination(apify_client: ApifyClient) -> None: + """Test store listing pagination.""" + page1 = apify_client.store().list(limit=5, offset=0) + page2 = apify_client.store().list(limit=5, offset=5) + + assert page1 is not None + assert page2 is not None + # Verify different results (if enough actors exist) + if len(page1.items) == 5 and len(page2.items) > 0: + assert page1.items[0].id != page2.items[0].id diff --git a/tests/integration/test_store_async.py b/tests/integration/test_store_async.py new file mode 100644 index 00000000..e20dda77 --- /dev/null +++ b/tests/integration/test_store_async.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + + +async def test_store_list(apify_client_async: ApifyClientAsync) -> None: + """Test listing public actors in the store.""" + actors_list = await apify_client_async.store().list(limit=10) + assert actors_list is not None + assert actors_list.items is not None + assert len(actors_list.items) > 0 # Store always has actors + + +async def test_store_list_with_search(apify_client_async: ApifyClientAsync) -> None: + """Test listing store with search filter.""" + store_page = await apify_client_async.store().list(limit=5, search='web scraper') + + assert store_page is not None + assert store_page.items is not None + assert isinstance(store_page.items, list) + + +async def test_store_list_pagination(apify_client_async: ApifyClientAsync) -> None: + """Test store listing pagination.""" + page1 = await apify_client_async.store().list(limit=5, offset=0) + page2 = await apify_client_async.store().list(limit=5, offset=5) + + assert page1 is not None + assert page2 is not None + # Verify different results (if enough actors exist) + if len(page1.items) == 5 and len(page2.items) > 0: + assert page1.items[0].id != page2.items[0].id diff --git a/tests/integration/test_task.py b/tests/integration/test_task.py new file mode 100644 index 00000000..98f07f94 --- /dev/null +++ b/tests/integration/test_task.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .utils import get_random_resource_name + +if TYPE_CHECKING: + from apify_client import ApifyClient + +# Use a simple, fast public actor for testing +HELLO_WORLD_ACTOR = 'apify/hello-world' + + +def test_task_create_and_get(apify_client: ApifyClient) -> None: + """Test creating a task and retrieving it.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + actor_id = actor.id + + # Create task + created_task = apify_client.tasks().create( + actor_id=actor_id, + name=task_name, + ) + assert created_task is not None + assert created_task.id is not None + assert created_task.name == task_name + assert created_task.act_id == actor_id + + # Get the same task + task_client = apify_client.task(created_task.id) + retrieved_task = task_client.get() + assert retrieved_task is not None + assert retrieved_task.id == created_task.id + assert retrieved_task.name == task_name + + # Cleanup + task_client.delete() + + +def test_task_update(apify_client: ApifyClient) -> None: + """Test updating task properties.""" + task_name = get_random_resource_name('task') + new_name = get_random_resource_name('task-updated') + + # Get the actor ID for hello-world + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = apify_client.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client.task(created_task.id) + + # Update the task + updated_task = task_client.update( + name=new_name, + timeout_secs=300, + ) + assert updated_task is not None + assert updated_task.name == new_name + assert updated_task.id == created_task.id + + # Verify the update persisted + retrieved_task = task_client.get() + assert retrieved_task is not None + assert retrieved_task.name == new_name + + # Cleanup + task_client.delete() + + +def test_task_list(apify_client: ApifyClient) -> None: + """Test listing tasks.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create a task + created_task = apify_client.tasks().create( + actor_id=actor.id, + name=task_name, + ) + + # List tasks + tasks_page = apify_client.tasks().list(limit=100) + assert tasks_page is not None + assert tasks_page.items is not None + + # Verify our task is in the list + task_ids = [t.id for t in tasks_page.items] + assert created_task.id in task_ids + + # Cleanup + apify_client.task(created_task.id).delete() + + +def test_task_get_input(apify_client: ApifyClient) -> None: + """Test getting and updating task input.""" + task_name = get_random_resource_name('task') + test_input = {'message': 'Hello from test'} + + # Get the actor ID for hello-world + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task with input + created_task = apify_client.tasks().create( + actor_id=actor.id, + name=task_name, + task_input=test_input, + ) + task_client = apify_client.task(created_task.id) + + # Get input + retrieved_input = task_client.get_input() + assert retrieved_input is not None + assert retrieved_input.get('message') == 'Hello from test' + + # Update input + new_input = {'message': 'Updated message'} + updated_input = task_client.update_input(task_input=new_input) + assert updated_input is not None + assert updated_input.get('message') == 'Updated message' + + # Cleanup + task_client.delete() + + +def test_task_start(apify_client: ApifyClient) -> None: + """Test starting a task run.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = apify_client.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client.task(created_task.id) + + # Start the task + run = task_client.start() + assert run is not None + assert run.id is not None + assert run.act_id == actor.id + + # Wait for the run to finish + finished_run = apify_client.run(run.id).wait_for_finish() + assert finished_run is not None + assert finished_run.status.value == 'SUCCEEDED' + + # Cleanup + apify_client.run(run.id).delete() + task_client.delete() + + +def test_task_call(apify_client: ApifyClient) -> None: + """Test calling a task and waiting for completion.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = apify_client.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client.task(created_task.id) + + # Call the task (waits for finish) + run = task_client.call() + assert run is not None + assert run.id is not None + assert run.status.value == 'SUCCEEDED' + + # Cleanup + apify_client.run(run.id).delete() + task_client.delete() + + +def test_task_delete(apify_client: ApifyClient) -> None: + """Test deleting a task.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = apify_client.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client.task(created_task.id) + + # Delete task + task_client.delete() + + # Verify it's gone + retrieved_task = task_client.get() + assert retrieved_task is None + + +def test_task_runs(apify_client: ApifyClient) -> None: + """Test listing task runs.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = apify_client.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client.task(created_task.id) + + try: + # Run the task + run = task_client.call() + assert run is not None + + # List runs for this task + runs_client = task_client.runs() + runs_page = runs_client.list(limit=10) + assert runs_page is not None + assert runs_page.items is not None + assert len(runs_page.items) >= 1 + + # Cleanup run + apify_client.run(run.id).delete() + + finally: + # Cleanup task + task_client.delete() + + +def test_task_last_run(apify_client: ApifyClient) -> None: + """Test getting the last run of a task.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = apify_client.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client.task(created_task.id) + + try: + # Run the task + run = task_client.call() + assert run is not None + + # Get last run client + last_run_client = task_client.last_run() + last_run = last_run_client.get() + assert last_run is not None + assert last_run.id == run.id + + # Cleanup run + apify_client.run(run.id).delete() + + finally: + # Cleanup task + task_client.delete() + + +def test_task_webhooks(apify_client: ApifyClient) -> None: + """Test listing webhooks for a task.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = apify_client.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client.task(created_task.id) + + try: + # Get webhooks client + webhooks_client = task_client.webhooks() + webhooks_page = webhooks_client.list() + assert webhooks_page is not None + assert webhooks_page.items is not None + # New task should have no webhooks + assert len(webhooks_page.items) == 0 + + finally: + # Cleanup task + task_client.delete() diff --git a/tests/integration/test_task_async.py b/tests/integration/test_task_async.py new file mode 100644 index 00000000..05c2cc95 --- /dev/null +++ b/tests/integration/test_task_async.py @@ -0,0 +1,312 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from .utils import get_random_resource_name + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + +# Use a simple, fast public actor for testing +HELLO_WORLD_ACTOR = 'apify/hello-world' + + +async def test_task_create_and_get(apify_client_async: ApifyClientAsync) -> None: + """Test creating a task and retrieving it.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + actor_id = actor.id + + # Create task + created_task = await apify_client_async.tasks().create( + actor_id=actor_id, + name=task_name, + ) + assert created_task is not None + assert created_task.id is not None + assert created_task.name == task_name + assert created_task.act_id == actor_id + + # Get the same task + task_client = apify_client_async.task(created_task.id) + retrieved_task = await task_client.get() + assert retrieved_task is not None + assert retrieved_task.id == created_task.id + assert retrieved_task.name == task_name + + # Cleanup + await task_client.delete() + + +async def test_task_update(apify_client_async: ApifyClientAsync) -> None: + """Test updating task properties.""" + task_name = get_random_resource_name('task') + new_name = get_random_resource_name('task-updated') + + # Get the actor ID for hello-world + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = await apify_client_async.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client_async.task(created_task.id) + + # Update the task + updated_task = await task_client.update( + name=new_name, + timeout_secs=300, + ) + assert updated_task is not None + assert updated_task.name == new_name + assert updated_task.id == created_task.id + + # Verify the update persisted + retrieved_task = await task_client.get() + assert retrieved_task is not None + assert retrieved_task.name == new_name + + # Cleanup + await task_client.delete() + + +async def test_task_list(apify_client_async: ApifyClientAsync) -> None: + """Test listing tasks.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create a task + created_task = await apify_client_async.tasks().create( + actor_id=actor.id, + name=task_name, + ) + + # List tasks + tasks_page = await apify_client_async.tasks().list(limit=100) + assert tasks_page is not None + assert tasks_page.items is not None + + # Verify our task is in the list + task_ids = [t.id for t in tasks_page.items] + assert created_task.id in task_ids + + # Cleanup + await apify_client_async.task(created_task.id).delete() + + +async def test_task_get_input(apify_client_async: ApifyClientAsync) -> None: + """Test getting and updating task input.""" + task_name = get_random_resource_name('task') + test_input = {'message': 'Hello from test'} + + # Get the actor ID for hello-world + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task with input + created_task = await apify_client_async.tasks().create( + actor_id=actor.id, + name=task_name, + task_input=test_input, + ) + task_client = apify_client_async.task(created_task.id) + + # Get input + retrieved_input = await task_client.get_input() + assert retrieved_input is not None + assert retrieved_input.get('message') == 'Hello from test' + + # Update input + new_input = {'message': 'Updated message'} + updated_input = await task_client.update_input(task_input=new_input) + assert updated_input is not None + assert updated_input.get('message') == 'Updated message' + + # Cleanup + await task_client.delete() + + +async def test_task_start(apify_client_async: ApifyClientAsync) -> None: + """Test starting a task run.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = await apify_client_async.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client_async.task(created_task.id) + + # Start the task + run = await task_client.start() + assert run is not None + assert run.id is not None + assert run.act_id == actor.id + + # Wait for the run to finish + finished_run = await apify_client_async.run(run.id).wait_for_finish() + assert finished_run is not None + assert finished_run.status.value == 'SUCCEEDED' + + # Cleanup + await apify_client_async.run(run.id).delete() + await task_client.delete() + + +async def test_task_call(apify_client_async: ApifyClientAsync) -> None: + """Test calling a task and waiting for completion.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = await apify_client_async.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client_async.task(created_task.id) + + # Call the task (waits for finish) + run = await task_client.call() + assert run is not None + assert run.id is not None + assert run.status.value == 'SUCCEEDED' + + # Cleanup + await apify_client_async.run(run.id).delete() + await task_client.delete() + + +async def test_task_delete(apify_client_async: ApifyClientAsync) -> None: + """Test deleting a task.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = await apify_client_async.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client_async.task(created_task.id) + + # Delete task + await task_client.delete() + + # Verify it's gone + retrieved_task = await task_client.get() + assert retrieved_task is None + + +async def test_task_runs(apify_client_async: ApifyClientAsync) -> None: + """Test listing task runs.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = await apify_client_async.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client_async.task(created_task.id) + + try: + # Run the task + run = await task_client.call() + assert run is not None + + # List runs for this task + runs_client = task_client.runs() + runs_page = await runs_client.list(limit=10) + assert runs_page is not None + assert runs_page.items is not None + assert len(runs_page.items) >= 1 + + # Cleanup run + await apify_client_async.run(run.id).delete() + + finally: + # Cleanup task + await task_client.delete() + + +async def test_task_last_run(apify_client_async: ApifyClientAsync) -> None: + """Test getting the last run of a task.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = await apify_client_async.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client_async.task(created_task.id) + + try: + # Run the task + run = await task_client.call() + assert run is not None + + # Get last run client + last_run_client = task_client.last_run() + last_run = await last_run_client.get() + assert last_run is not None + assert last_run.id == run.id + + # Cleanup run + await apify_client_async.run(run.id).delete() + + finally: + # Cleanup task + await task_client.delete() + + +async def test_task_webhooks(apify_client_async: ApifyClientAsync) -> None: + """Test listing webhooks for a task.""" + task_name = get_random_resource_name('task') + + # Get the actor ID for hello-world + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create task + created_task = await apify_client_async.tasks().create( + actor_id=actor.id, + name=task_name, + ) + task_client = apify_client_async.task(created_task.id) + + try: + # Get webhooks client + webhooks_client = task_client.webhooks() + webhooks_page = await webhooks_client.list() + assert webhooks_page is not None + assert webhooks_page.items is not None + # New task should have no webhooks + assert len(webhooks_page.items) == 0 + + finally: + # Cleanup task + await task_client.delete() diff --git a/tests/integration/test_user.py b/tests/integration/test_user.py new file mode 100644 index 00000000..3ca3085d --- /dev/null +++ b/tests/integration/test_user.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from apify_client.errors import ApifyApiError + +if TYPE_CHECKING: + from apify_client import ApifyClient + + +def test_get_user(apify_client: ApifyClient) -> None: + """Test getting user information.""" + user = apify_client.user().get() + + assert user is not None + # UserPublicInfo has username but not id + assert user.username is not None + + +def test_limits(apify_client: ApifyClient) -> None: + """Test getting account limits.""" + limits = apify_client.user().limits() + + # Verify we have at least some limit information. The actual fields depend on the account type. + assert limits is not None + + +def test_monthly_usage(apify_client: ApifyClient) -> None: + """Test retrieving monthly usage information.""" + usage = apify_client.user().monthly_usage() + + assert usage is not None + # Verify expected fields exist + assert usage.usage_cycle is not None + assert isinstance(usage.monthly_service_usage, dict) + assert isinstance(usage.daily_service_usages, list) + + +def test_update_limits(apify_client: ApifyClient) -> None: + """Test updating account limits. + + Note: This test verifies that the update_limits method can be called. On free accounts, the API will reject + changes to maxMonthlyUsageUsd, but dataRetentionDays can potentially be updated. + """ + user_client = apify_client.user() + + # Get current limits to see what's available + current_limits = user_client.limits() + assert current_limits is not None + + # Try to update data retention days (allowed on most accounts). We try to set it to the current + # value or a reasonable default. + try: + # Try updating with just data_retention_days + user_client.update_limits(data_retention_days=7) + # If it succeeds, the update was applied (or same value was set) + except ApifyApiError as exc: + # Some accounts may not allow updating limits - re-raise if unexpected. + # This is expected for certain account types. + if exc.status_code not in [400, 403]: + raise diff --git a/tests/integration/test_user_async.py b/tests/integration/test_user_async.py new file mode 100644 index 00000000..74f3d2bf --- /dev/null +++ b/tests/integration/test_user_async.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from apify_client.errors import ApifyApiError + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + + +async def test_get_user(apify_client_async: ApifyClientAsync) -> None: + """Test getting user information.""" + user = await apify_client_async.user().get() + + assert user is not None + # UserPublicInfo has username but not id + assert user.username is not None + + +async def test_limits(apify_client_async: ApifyClientAsync) -> None: + """Test getting account limits.""" + limits = await apify_client_async.user().limits() + + # Verify we have at least some limit information. The actual fields depend on the account type. + assert limits is not None + + +async def test_monthly_usage(apify_client_async: ApifyClientAsync) -> None: + """Test retrieving monthly usage information.""" + usage = await apify_client_async.user().monthly_usage() + + assert usage is not None + # Verify expected fields exist + assert usage.usage_cycle is not None + assert isinstance(usage.monthly_service_usage, dict) + assert isinstance(usage.daily_service_usages, list) + + +async def test_update_limits(apify_client_async: ApifyClientAsync) -> None: + """Test updating account limits. + + Note: This test verifies that the update_limits method can be called. On free accounts, the API will reject + changes to maxMonthlyUsageUsd, but dataRetentionDays can potentially be updated. + """ + user_client = apify_client_async.user() + + # Get current limits to see what's available + current_limits = await user_client.limits() + assert current_limits is not None + + # Try to update data retention days (allowed on most accounts). We try to set it to the current + # value or a reasonable default. + try: + # Try updating with just data_retention_days + await user_client.update_limits(data_retention_days=7) + # If it succeeds, the update was applied (or same value was set) + except ApifyApiError as exc: + # Some accounts may not allow updating limits - re-raise if unexpected. + # This is expected for certain account types. + if exc.status_code not in [400, 403]: + raise diff --git a/tests/integration/test_webhook.py b/tests/integration/test_webhook.py new file mode 100644 index 00000000..256f99ec --- /dev/null +++ b/tests/integration/test_webhook.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from apify_client._consts import WebhookEventType + +if TYPE_CHECKING: + from apify_client import ApifyClient + +HELLO_WORLD_ACTOR = 'apify/hello-world' + + +def test_list_webhooks(apify_client: ApifyClient) -> None: + """Test listing webhooks.""" + webhooks_page = apify_client.webhooks().list(limit=10) + + assert webhooks_page is not None + assert webhooks_page.items is not None + # User may have 0 webhooks + assert isinstance(webhooks_page.items, list) + + +def test_list_webhooks_pagination(apify_client: ApifyClient) -> None: + """Test listing webhooks with pagination.""" + webhooks_page = apify_client.webhooks().list(limit=5, offset=0) + + assert webhooks_page is not None + assert webhooks_page.items is not None + assert isinstance(webhooks_page.items, list) + + +def test_webhook_create_and_get(apify_client: ApifyClient) -> None: + """Test creating a webhook and retrieving it.""" + # Get actor ID for webhook condition + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create webhook (use httpbin as dummy endpoint) + created_webhook = apify_client.webhooks().create( + event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED], + request_url='https://httpbin.org/post', + actor_id=actor.id, + ) + assert created_webhook is not None + assert created_webhook.id is not None + + # Get the same webhook + webhook_client = apify_client.webhook(created_webhook.id) + retrieved_webhook = webhook_client.get() + assert retrieved_webhook is not None + assert retrieved_webhook.id == created_webhook.id + + # Cleanup + webhook_client.delete() + + +def test_webhook_update(apify_client: ApifyClient) -> None: + """Test updating a webhook.""" + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create webhook + created_webhook = apify_client.webhooks().create( + event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED], + request_url='https://httpbin.org/post', + actor_id=actor.id, + ) + webhook_client = apify_client.webhook(created_webhook.id) + + # Update webhook (must include actor_id as condition is required) + updated_webhook = webhook_client.update( + request_url='https://httpbin.org/anything', + actor_id=actor.id, + ) + assert str(updated_webhook.request_url) == 'https://httpbin.org/anything' + + # Cleanup + webhook_client.delete() + + +def test_webhook_test(apify_client: ApifyClient) -> None: + """Test the webhook test endpoint.""" + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create webhook + created_webhook = apify_client.webhooks().create( + event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED], + request_url='https://httpbin.org/post', + actor_id=actor.id, + ) + webhook_client = apify_client.webhook(created_webhook.id) + + # Test webhook (creates a dispatch) + dispatch = webhook_client.test() + assert dispatch is not None + assert dispatch.id is not None + + # Cleanup + webhook_client.delete() + + +def test_webhook_dispatches(apify_client: ApifyClient) -> None: + """Test listing webhook dispatches.""" + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create webhook + created_webhook = apify_client.webhooks().create( + event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED], + request_url='https://httpbin.org/post', + actor_id=actor.id, + ) + webhook_client = apify_client.webhook(created_webhook.id) + + # Test webhook to create a dispatch + webhook_client.test() + + # List dispatches for this webhook + dispatches = webhook_client.dispatches().list() + assert dispatches is not None + assert dispatches.items is not None + assert len(dispatches.items) > 0 + + # Cleanup + webhook_client.delete() + + +def test_webhook_delete(apify_client: ApifyClient) -> None: + """Test deleting a webhook.""" + actor = apify_client.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create webhook + created_webhook = apify_client.webhooks().create( + event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED], + request_url='https://httpbin.org/post', + actor_id=actor.id, + ) + webhook_client = apify_client.webhook(created_webhook.id) + + # Delete webhook + webhook_client.delete() + + # Verify it's gone + retrieved_webhook = webhook_client.get() + assert retrieved_webhook is None diff --git a/tests/integration/test_webhook_async.py b/tests/integration/test_webhook_async.py new file mode 100644 index 00000000..77830597 --- /dev/null +++ b/tests/integration/test_webhook_async.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from apify_client._consts import WebhookEventType + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + +HELLO_WORLD_ACTOR = 'apify/hello-world' + + +async def test_list_webhooks(apify_client_async: ApifyClientAsync) -> None: + """Test listing webhooks.""" + webhooks_page = await apify_client_async.webhooks().list(limit=10) + + assert webhooks_page is not None + assert webhooks_page.items is not None + # User may have 0 webhooks + assert isinstance(webhooks_page.items, list) + + +async def test_list_webhooks_pagination(apify_client_async: ApifyClientAsync) -> None: + """Test listing webhooks with pagination.""" + webhooks_page = await apify_client_async.webhooks().list(limit=5, offset=0) + + assert webhooks_page is not None + assert webhooks_page.items is not None + assert isinstance(webhooks_page.items, list) + + +async def test_webhook_create_and_get(apify_client_async: ApifyClientAsync) -> None: + """Test creating a webhook and retrieving it.""" + # Get actor ID for webhook condition + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create webhook (use httpbin as dummy endpoint) + created_webhook = await apify_client_async.webhooks().create( + event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED], + request_url='https://httpbin.org/post', + actor_id=actor.id, + ) + assert created_webhook is not None + assert created_webhook.id is not None + + # Get the same webhook + webhook_client = apify_client_async.webhook(created_webhook.id) + retrieved_webhook = await webhook_client.get() + assert retrieved_webhook is not None + assert retrieved_webhook.id == created_webhook.id + + # Cleanup + await webhook_client.delete() + + +async def test_webhook_update(apify_client_async: ApifyClientAsync) -> None: + """Test updating a webhook.""" + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create webhook + created_webhook = await apify_client_async.webhooks().create( + event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED], + request_url='https://httpbin.org/post', + actor_id=actor.id, + ) + webhook_client = apify_client_async.webhook(created_webhook.id) + + # Update webhook (must include actor_id as condition is required) + updated_webhook = await webhook_client.update( + request_url='https://httpbin.org/anything', + actor_id=actor.id, + ) + assert str(updated_webhook.request_url) == 'https://httpbin.org/anything' + + # Cleanup + await webhook_client.delete() + + +async def test_webhook_test(apify_client_async: ApifyClientAsync) -> None: + """Test the webhook test endpoint.""" + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create webhook + created_webhook = await apify_client_async.webhooks().create( + event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED], + request_url='https://httpbin.org/post', + actor_id=actor.id, + ) + webhook_client = apify_client_async.webhook(created_webhook.id) + + # Test webhook (creates a dispatch) + dispatch = await webhook_client.test() + assert dispatch is not None + assert dispatch.id is not None + + # Cleanup + await webhook_client.delete() + + +async def test_webhook_dispatches(apify_client_async: ApifyClientAsync) -> None: + """Test listing webhook dispatches.""" + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create webhook + created_webhook = await apify_client_async.webhooks().create( + event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED], + request_url='https://httpbin.org/post', + actor_id=actor.id, + ) + webhook_client = apify_client_async.webhook(created_webhook.id) + + # Test webhook to create a dispatch + await webhook_client.test() + + # List dispatches for this webhook + dispatches = await webhook_client.dispatches().list() + assert dispatches is not None + assert dispatches.items is not None + assert len(dispatches.items) > 0 + + # Cleanup + await webhook_client.delete() + + +async def test_webhook_delete(apify_client_async: ApifyClientAsync) -> None: + """Test deleting a webhook.""" + actor = await apify_client_async.actor(HELLO_WORLD_ACTOR).get() + assert actor is not None + + # Create webhook + created_webhook = await apify_client_async.webhooks().create( + event_types=[WebhookEventType.ACTOR_RUN_SUCCEEDED], + request_url='https://httpbin.org/post', + actor_id=actor.id, + ) + webhook_client = apify_client_async.webhook(created_webhook.id) + + # Delete webhook + await webhook_client.delete() + + # Verify it's gone + retrieved_webhook = await webhook_client.get() + assert retrieved_webhook is None diff --git a/tests/integration/test_webhook_dispatch.py b/tests/integration/test_webhook_dispatch.py new file mode 100644 index 00000000..21ea4a73 --- /dev/null +++ b/tests/integration/test_webhook_dispatch.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apify_client import ApifyClient + + +def test_webhook_dispatch_list(apify_client: ApifyClient) -> None: + """Test listing webhook dispatches.""" + dispatches_page = apify_client.webhook_dispatches().list(limit=10) + + assert dispatches_page is not None + assert dispatches_page.items is not None + assert isinstance(dispatches_page.items, list) + # User may have 0 dispatches, so we just verify the structure + + +def test_webhook_dispatch_get(apify_client: ApifyClient) -> None: + """Test getting a specific webhook dispatch.""" + # First list dispatches to get a dispatch ID + dispatches_page = apify_client.webhook_dispatches().list(limit=1) + assert dispatches_page is not None + + if dispatches_page.items: + # If there are dispatches, test the get method + dispatch_id = dispatches_page.items[0].id + dispatch = apify_client.webhook_dispatch(dispatch_id).get() + + assert dispatch is not None + assert dispatch.id == dispatch_id + else: + # If no dispatches, test that get returns None for non-existent ID + dispatch = apify_client.webhook_dispatch('non-existent-id').get() + assert dispatch is None diff --git a/tests/integration/test_webhook_dispatch_async.py b/tests/integration/test_webhook_dispatch_async.py new file mode 100644 index 00000000..3dd254b5 --- /dev/null +++ b/tests/integration/test_webhook_dispatch_async.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from apify_client import ApifyClientAsync + + +async def test_webhook_dispatch_list(apify_client_async: ApifyClientAsync) -> None: + """Test listing webhook dispatches.""" + dispatches_page = await apify_client_async.webhook_dispatches().list(limit=10) + + assert dispatches_page is not None + assert dispatches_page.items is not None + assert isinstance(dispatches_page.items, list) + # User may have 0 dispatches, so we just verify the structure + + +async def test_webhook_dispatch_get(apify_client_async: ApifyClientAsync) -> None: + """Test getting a specific webhook dispatch.""" + # First list dispatches to get a dispatch ID + dispatches_page = await apify_client_async.webhook_dispatches().list(limit=1) + assert dispatches_page is not None + + if dispatches_page.items: + # If there are dispatches, test the get method + dispatch_id = dispatches_page.items[0].id + dispatch = await apify_client_async.webhook_dispatch(dispatch_id).get() + + assert dispatch is not None + assert dispatch.id == dispatch_id + else: + # If no dispatches, test that get returns None for non-existent ID + dispatch = await apify_client_async.webhook_dispatch('non-existent-id').get() + assert dispatch is None diff --git a/tests/integration/integration_test_utils.py b/tests/integration/utils.py similarity index 50% rename from tests/integration/integration_test_utils.py rename to tests/integration/utils.py index 6d7fc6bb..c2403eef 100644 --- a/tests/integration/integration_test_utils.py +++ b/tests/integration/utils.py @@ -6,14 +6,46 @@ import pytest -def random_string(length: int = 10) -> str: +@dataclasses.dataclass +class StorageFixture: + """Storage resource fixture with ID and signature.""" + + id: str + signature: str + + +@dataclasses.dataclass +class DatasetFixture(StorageFixture): + """Dataset fixture with expected content.""" + + expected_content: list + + +@dataclasses.dataclass +class KvsFixture(StorageFixture): + """Key-value store fixture with expected content and key signatures.""" + + expected_content: dict[str, Any] + keys_signature: dict[str, str] + + +def get_crypto_random_object_id(length: int = 17) -> str: + """Generate a cryptographically secure random object ID.""" + chars = 'abcdefghijklmnopqrstuvwxyzABCEDFGHIJKLMNOPQRSTUVWXYZ0123456789' + return ''.join(secrets.choice(chars) for _ in range(length)) + + +def get_random_string(length: int = 10) -> str: + """Generate a random alphabetic string.""" return ''.join(secrets.choice(string.ascii_letters) for _ in range(length)) -def random_resource_name(resource: str) -> str: - return f'python-client-test-{resource}-{random_string(5)}' +def get_random_resource_name(resource: str) -> str: + """Generate a random resource name for test resources.""" + return f'python-client-test-{resource}-{get_random_string(5)}' +# Parametrize decorator for testing various API URL and public URL combinations parametrized_api_urls = pytest.mark.parametrize( ('api_url', 'api_public_url'), [ @@ -26,20 +58,3 @@ def random_resource_name(resource: str) -> str: ('http://10.0.88.214:8010', None), ], ) - - -@dataclasses.dataclass -class TestStorage: - id: str - signature: str - - -@dataclasses.dataclass -class TestDataset(TestStorage): - expected_content: list - - -@dataclasses.dataclass -class TestKvs(TestStorage): - expected_content: dict[str, Any] - keys_signature: dict[str, str] diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index b68e2420..c6a3ea9d 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -28,6 +28,6 @@ def httpserver(make_httpserver: HTTPServer) -> Iterable[HTTPServer]: @pytest.fixture def patch_basic_url(httpserver: HTTPServer, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]: server_url = httpserver.url_for('/').removesuffix('/') - monkeypatch.setattr('apify_client.client.DEFAULT_API_URL', server_url) + monkeypatch.setattr('apify_client._config.DEFAULT_API_URL', server_url) yield monkeypatch.undo() diff --git a/tests/unit/test_client_errors.py b/tests/unit/test_client_errors.py index 19833e7b..94622e2e 100644 --- a/tests/unit/test_client_errors.py +++ b/tests/unit/test_client_errors.py @@ -7,7 +7,8 @@ import pytest from werkzeug import Response -from apify_client._http_client import HTTPClient, HTTPClientAsync +from apify_client._config import ClientConfig +from apify_client._http_clients import HttpClient, HttpClientAsync from apify_client.errors import ApifyApiError if TYPE_CHECKING: @@ -34,6 +35,11 @@ ) +@pytest.fixture +def test_config() -> ClientConfig: + return ClientConfig.from_user_params() + + @pytest.fixture def test_endpoint(httpserver: HTTPServer) -> str: httpserver.expect_request(_TEST_PATH).respond_with_json( @@ -58,57 +64,57 @@ def generate_response() -> Iterator[bytes]: ) -def test_client_apify_api_error_with_data(test_endpoint: str) -> None: +def test_client_apify_api_error_with_data(test_endpoint: str, test_config: ClientConfig) -> None: """Test that client correctly throws ApifyApiError with error data from response.""" - client = HTTPClient() + client = HttpClient(config=test_config) - with pytest.raises(ApifyApiError) as e: + with pytest.raises(ApifyApiError) as exc: client.call(method='GET', url=test_endpoint) - assert e.value.message == _EXPECTED_MESSAGE - assert e.value.type == _EXPECTED_TYPE - assert e.value.data == _EXPECTED_DATA + assert exc.value.message == _EXPECTED_MESSAGE + assert exc.value.type == _EXPECTED_TYPE + assert exc.value.data == _EXPECTED_DATA -async def test_async_client_apify_api_error_with_data(test_endpoint: str) -> None: +async def test_async_client_apify_api_error_with_data(test_endpoint: str, test_config: ClientConfig) -> None: """Test that async client correctly throws ApifyApiError with error data from response.""" - client = HTTPClientAsync() + client = HttpClientAsync(config=test_config) - with pytest.raises(ApifyApiError) as e: + with pytest.raises(ApifyApiError) as exc: await client.call(method='GET', url=test_endpoint) - assert e.value.message == _EXPECTED_MESSAGE - assert e.value.type == _EXPECTED_TYPE - assert e.value.data == _EXPECTED_DATA + assert exc.value.message == _EXPECTED_MESSAGE + assert exc.value.type == _EXPECTED_TYPE + assert exc.value.data == _EXPECTED_DATA -def test_client_apify_api_error_streamed(httpserver: HTTPServer) -> None: +def test_client_apify_api_error_streamed(httpserver: HTTPServer, test_config: ClientConfig) -> None: """Test that client correctly throws ApifyApiError when the response has stream.""" error = json.loads(RAW_ERROR.decode()) - client = HTTPClient() + client = HttpClient(config=test_config) httpserver.expect_request('/stream_error').respond_with_handler(streaming_handler) - with pytest.raises(ApifyApiError) as e: + with pytest.raises(ApifyApiError) as exc: client.call(method='GET', url=httpserver.url_for('/stream_error'), stream=True) - assert e.value.message == error['error']['message'] - assert e.value.type == error['error']['type'] + assert exc.value.message == error['error']['message'] + assert exc.value.type == error['error']['type'] -async def test_async_client_apify_api_error_streamed(httpserver: HTTPServer) -> None: +async def test_async_client_apify_api_error_streamed(httpserver: HTTPServer, test_config: ClientConfig) -> None: """Test that async client correctly throws ApifyApiError when the response has stream.""" error = json.loads(RAW_ERROR.decode()) - client = HTTPClientAsync() + client = HttpClientAsync(config=test_config) httpserver.expect_request('/stream_error').respond_with_handler(streaming_handler) - with pytest.raises(ApifyApiError) as e: + with pytest.raises(ApifyApiError) as exc: await client.call(method='GET', url=httpserver.url_for('/stream_error'), stream=True) - assert e.value.message == error['error']['message'] - assert e.value.type == error['error']['type'] + assert exc.value.message == error['error']['message'] + assert exc.value.type == error['error']['type'] diff --git a/tests/unit/test_client_request_queue.py b/tests/unit/test_client_request_queue.py index d7ff9cf1..6e2b28fa 100644 --- a/tests/unit/test_client_request_queue.py +++ b/tests/unit/test_client_request_queue.py @@ -60,9 +60,10 @@ async def test_batch_processed_partially_async(httpserver: HTTPServer) -> None: ] rq_client = client.request_queue(request_queue_id='whatever') - response = await rq_client.batch_add_requests(requests=requests) - assert requests[0]['uniqueKey'] in {request['uniqueKey'] for request in response['processedRequests']} - assert response['unprocessedRequests'] == [requests[1]] + batch_response = await rq_client.batch_add_requests(requests=requests) + assert requests[0]['uniqueKey'] in {request.unique_key for request in batch_response.processed_requests} + assert len(batch_response.unprocessed_requests) == 1 + assert batch_response.unprocessed_requests[0].unique_key == requests[1]['uniqueKey'] @pytest.mark.usefixtures('patch_basic_url') @@ -94,6 +95,7 @@ async def test_batch_processed_partially_sync(httpserver: HTTPServer) -> None: ] rq_client = client.request_queue(request_queue_id='whatever') - response = rq_client.batch_add_requests(requests=requests) - assert requests[0]['uniqueKey'] in {request['uniqueKey'] for request in response['processedRequests']} - assert response['unprocessedRequests'] == [requests[1]] + batch_response = rq_client.batch_add_requests(requests=requests) + assert requests[0]['uniqueKey'] in {request.unique_key for request in batch_response.processed_requests} + assert len(batch_response.unprocessed_requests) == 1 + assert batch_response.unprocessed_requests[0].unique_key == requests[1]['uniqueKey'] diff --git a/tests/unit/test_client_timeouts.py b/tests/unit/test_client_timeouts.py index 70d09b90..9f84813a 100644 --- a/tests/unit/test_client_timeouts.py +++ b/tests/unit/test_client_timeouts.py @@ -6,18 +6,12 @@ import pytest from impit import Response, TimeoutException -from apify_client import ApifyClient -from apify_client._http_client import HTTPClient, HTTPClientAsync -from apify_client.client import DEFAULT_TIMEOUT -from apify_client.clients import DatasetClient, KeyValueStoreClient, RequestQueueClient -from apify_client.clients.resource_clients import dataset, request_queue -from apify_client.clients.resource_clients import key_value_store as kvs +from apify_client._config import ClientConfig +from apify_client._http_clients import HttpClient, HttpClientAsync if TYPE_CHECKING: from collections.abc import Iterator - from pytest_httpserver import HTTPServer - class EndOfTestError(Exception): """Custom exception that is raised after the relevant part of the code is executed to stop the test.""" @@ -64,7 +58,8 @@ async def mock_request(*_args: Any, **kwargs: Any) -> Response: monkeypatch.setattr('impit.AsyncClient.request', mock_request) - response = await HTTPClientAsync(timeout_secs=client_timeout).call( + config = ClientConfig.from_user_params(timeout_secs=client_timeout) + response = await HttpClientAsync(config=config).call( method='GET', url='http://placeholder.url/async_timeout', timeout_secs=call_timeout ) @@ -100,7 +95,8 @@ def mock_request(*_args: Any, **kwargs: Any) -> Response: monkeypatch.setattr('impit.Client.request', mock_request) - response = HTTPClient(timeout_secs=client_timeout).call( + config = ClientConfig.from_user_params(timeout_secs=client_timeout) + response = HttpClient(config=config).call( method='GET', url='http://placeholder.url/sync_timeout', timeout_secs=call_timeout ) @@ -110,83 +106,3 @@ def mock_request(*_args: Any, **kwargs: Any) -> Response: assert timeouts == expected_timeouts # Check that the response is successful assert response.status_code == 200 - - -_timeout_params = [ - (DatasetClient, 'get', dataset._SMALL_TIMEOUT, {}), - (DatasetClient, 'update', dataset._SMALL_TIMEOUT, {}), - (DatasetClient, 'delete', dataset._SMALL_TIMEOUT, {}), - (DatasetClient, 'list_items', DEFAULT_TIMEOUT, {}), - (DatasetClient, 'download_items', DEFAULT_TIMEOUT, {}), - (DatasetClient, 'get_items_as_bytes', DEFAULT_TIMEOUT, {}), - (DatasetClient, 'push_items', dataset._MEDIUM_TIMEOUT, {'items': {}}), - (DatasetClient, 'get_statistics', dataset._SMALL_TIMEOUT, {}), - (KeyValueStoreClient, 'get', kvs._SMALL_TIMEOUT, {}), - (KeyValueStoreClient, 'update', DEFAULT_TIMEOUT, {}), - (KeyValueStoreClient, 'delete', kvs._SMALL_TIMEOUT, {}), - (KeyValueStoreClient, 'list_keys', kvs._MEDIUM_TIMEOUT, {}), - (KeyValueStoreClient, 'get_record', DEFAULT_TIMEOUT, {'key': 'some_key'}), - (KeyValueStoreClient, 'get_record_as_bytes', DEFAULT_TIMEOUT, {'key': 'some_key'}), - (KeyValueStoreClient, 'set_record', DEFAULT_TIMEOUT, {'key': 'some_key', 'value': 'some_value'}), - (KeyValueStoreClient, 'delete_record', kvs._SMALL_TIMEOUT, {'key': 'some_key'}), - (RequestQueueClient, 'get', request_queue._SMALL_TIMEOUT, {}), - (RequestQueueClient, 'update', request_queue._SMALL_TIMEOUT, {}), - (RequestQueueClient, 'delete', request_queue._SMALL_TIMEOUT, {}), - (RequestQueueClient, 'list_head', request_queue._SMALL_TIMEOUT, {}), - (RequestQueueClient, 'list_and_lock_head', request_queue._MEDIUM_TIMEOUT, {'lock_secs': 1}), - (RequestQueueClient, 'add_request', request_queue._SMALL_TIMEOUT, {'request': {}}), - (RequestQueueClient, 'get_request', request_queue._SMALL_TIMEOUT, {'request_id': 'some_id'}), - (RequestQueueClient, 'update_request', request_queue._MEDIUM_TIMEOUT, {'request': {'id': 123}}), - (RequestQueueClient, 'delete_request', request_queue._SMALL_TIMEOUT, {'request_id': 123}), - (RequestQueueClient, 'prolong_request_lock', request_queue._MEDIUM_TIMEOUT, {'request_id': 123, 'lock_secs': 1}), - (RequestQueueClient, 'delete_request_lock', request_queue._SMALL_TIMEOUT, {'request_id': 123}), - (RequestQueueClient, 'batch_add_requests', request_queue._MEDIUM_TIMEOUT, {'requests': [{}]}), - (RequestQueueClient, 'batch_delete_requests', request_queue._SMALL_TIMEOUT, {'requests': [{}]}), - (RequestQueueClient, 'list_requests', request_queue._MEDIUM_TIMEOUT, {}), -] - - -# This test will probably need to be reworked or skipped when switching to `impit`. -# Without the mock library, it's difficult to reproduce, maybe with monkeypatch? -@pytest.mark.parametrize( - ('client_type', 'method', 'expected_timeout', 'kwargs'), - _timeout_params, -) -def test_specific_timeouts_for_specific_endpoints_sync( - client_type: type[DatasetClient | KeyValueStoreClient | RequestQueueClient], - method: str, - kwargs: dict, - expected_timeout: int, - patch_request: list[float | None], - httpserver: HTTPServer, -) -> None: - httpserver.expect_request('/').respond_with_data(status=200) - client = client_type(base_url=httpserver.url_for('/'), root_client=ApifyClient(), http_client=HTTPClient()) - with pytest.raises(EndOfTestError): - getattr(client, method)(**kwargs) - - assert len(patch_request) == 1 - assert patch_request[0] == expected_timeout - - -# This test will probably need to be reworked or skipped when switching to `impit`. -# Without the mock library, it's difficult to reproduce, maybe with monkeypatch? -@pytest.mark.parametrize( - ('client_type', 'method', 'expected_timeout', 'kwargs'), - _timeout_params, -) -async def test_specific_timeouts_for_specific_endpoints_async( - client_type: type[DatasetClient | KeyValueStoreClient | RequestQueueClient], - method: str, - kwargs: dict, - expected_timeout: int, - patch_request: list[float | None], - httpserver: HTTPServer, -) -> None: - httpserver.expect_request('/').respond_with_data(status=200) - client = client_type(base_url=httpserver.url_for('/'), root_client=ApifyClient(), http_client=HTTPClient()) - with pytest.raises(EndOfTestError): - await getattr(client, method)(**kwargs) - - assert len(patch_request) == 1 - assert patch_request[0] == expected_timeout diff --git a/tests/unit/test_http_clients.py b/tests/unit/test_http_clients.py new file mode 100644 index 00000000..482c9cab --- /dev/null +++ b/tests/unit/test_http_clients.py @@ -0,0 +1,329 @@ +import time +from collections.abc import Callable +from datetime import datetime, timezone +from typing import Any +from unittest.mock import Mock + +import impit +import pytest + +from apify_client._config import ClientConfig +from apify_client._http_clients import HttpClient, HttpClientAsync +from apify_client._http_clients._base import BaseHttpClient +from apify_client._statistics import ClientStatistics +from apify_client.errors import InvalidResponseBodyError + + +def test_retry_with_exp_backoff() -> None: + attempt_counter = 0 + + class RetryableError(Exception): + pass + + class NonRetryableError(Exception): + pass + + def returns_on_fifth_attempt(_stop_retrying: Callable, attempt: int) -> Any: + nonlocal attempt_counter + attempt_counter += 1 + + if attempt == 5: + return 'SUCCESS' + raise RetryableError + + def bails_on_third_attempt(stop_retrying: Callable, attempt: int) -> Any: + nonlocal attempt_counter + attempt_counter += 1 + + if attempt == 3: + stop_retrying() + raise NonRetryableError + else: # noqa: RET506 + raise RetryableError + + # Returns the correct result after the correct time (should take 100 + 200 + 400 + 800 = 1500 ms) + start = time.time() + result = HttpClient._retry_with_exp_backoff( + returns_on_fifth_attempt, backoff_base_millis=100, backoff_factor=2, random_factor=0 + ) + elapsed_time_seconds = time.time() - start + assert result == 'SUCCESS' + assert attempt_counter == 5 + assert elapsed_time_seconds > 1.4 + assert elapsed_time_seconds < 2.0 + + # Stops retrying when failed for max_retries times + attempt_counter = 0 + with pytest.raises(RetryableError): + HttpClient._retry_with_exp_backoff(returns_on_fifth_attempt, max_retries=3, backoff_base_millis=1) + assert attempt_counter == 4 + + # Bails when the bail function is called + attempt_counter = 0 + with pytest.raises(NonRetryableError): + HttpClient._retry_with_exp_backoff(bails_on_third_attempt, backoff_base_millis=1) + assert attempt_counter == 3 + + +async def test_retry_with_exp_backoff_async() -> None: + attempt_counter = 0 + + class RetryableError(Exception): + pass + + class NonRetryableError(Exception): + pass + + async def returns_on_fifth_attempt(_stop_retrying: Callable, attempt: int) -> Any: + nonlocal attempt_counter + attempt_counter += 1 + + if attempt == 5: + return 'SUCCESS' + raise RetryableError + + async def bails_on_third_attempt(stop_retrying: Callable, attempt: int) -> Any: + nonlocal attempt_counter + attempt_counter += 1 + + if attempt == 3: + stop_retrying() + raise NonRetryableError + else: # noqa: RET506 + raise RetryableError + + # Returns the correct result after the correct time (should take 100 + 200 + 400 + 800 = 1500 ms) + start = time.time() + result = await HttpClientAsync._retry_with_exp_backoff( + returns_on_fifth_attempt, backoff_base_millis=100, backoff_factor=2, random_factor=0 + ) + elapsed_time_seconds = time.time() - start + assert result == 'SUCCESS' + assert attempt_counter == 5 + assert elapsed_time_seconds > 1.4 + assert elapsed_time_seconds < 2.0 + + # Stops retrying when failed for max_retries times + attempt_counter = 0 + with pytest.raises(RetryableError): + await HttpClientAsync._retry_with_exp_backoff(returns_on_fifth_attempt, max_retries=3, backoff_base_millis=1) + assert attempt_counter == 4 + + # Bails when the bail function is called + attempt_counter = 0 + with pytest.raises(NonRetryableError): + await HttpClientAsync._retry_with_exp_backoff(bails_on_third_attempt, backoff_base_millis=1) + assert attempt_counter == 3 + + +def test_base_http_client_initialization() -> None: + """Test BaseHttpClient initialization with various configurations.""" + config = ClientConfig.from_user_params( + token='test_token', + api_url='https://api.test.com', + timeout_secs=30, + max_retries=5, + ) + statistics = ClientStatistics() + + client = BaseHttpClient(config=config, statistics=statistics) + + assert client._config == config + assert client._statistics == statistics + assert isinstance(client.impit_client, impit.Client) + assert isinstance(client.impit_async_client, impit.AsyncClient) + + # Test without statistics (should create default) + client2 = BaseHttpClient(config=config) + assert isinstance(client2._statistics, ClientStatistics) + + +def test_base_http_client_creates_impit_clients() -> None: + """Test that BaseHttpClient creates impit clients correctly.""" + config = ClientConfig.from_user_params(token='test_token_123') + client = BaseHttpClient(config=config) + + # Check that impit clients are created + assert client.impit_client is not None + assert client.impit_async_client is not None + assert isinstance(client.impit_client, impit.Client) + assert isinstance(client.impit_async_client, impit.AsyncClient) + + +def test_parse_params_none() -> None: + """Test _parse_params with None input.""" + assert BaseHttpClient._parse_params(None) is None + + +def test_parse_params_boolean() -> None: + """Test _parse_params converts booleans to integers.""" + result = BaseHttpClient._parse_params({'flag': True, 'disabled': False}) + assert result == {'flag': 1, 'disabled': 0} + + +def test_parse_params_list() -> None: + """Test _parse_params converts lists to comma-separated strings.""" + result = BaseHttpClient._parse_params({'ids': ['id1', 'id2', 'id3']}) + assert result == {'ids': 'id1,id2,id3'} + + +def test_parse_params_datetime() -> None: + """Test _parse_params converts datetime to Zulu format.""" + dt = datetime(2024, 1, 15, 10, 30, 45, 123000, tzinfo=timezone.utc) + result = BaseHttpClient._parse_params({'created_at': dt}) + assert result == {'created_at': '2024-01-15T10:30:45.123Z'} + + +def test_parse_params_none_values_filtered() -> None: + """Test _parse_params filters out None values.""" + result = BaseHttpClient._parse_params({'a': 1, 'b': None, 'c': 'value'}) + assert result == {'a': 1, 'c': 'value'} + + +def test_parse_params_mixed() -> None: + """Test _parse_params with mixed types.""" + dt = datetime(2024, 1, 15, 10, 30, 45, 123000, tzinfo=timezone.utc) + result = BaseHttpClient._parse_params( + { + 'limit': 10, + 'offset': 0, + 'flag': True, + 'tags': ['tag1', 'tag2'], + 'created_at': dt, + 'name': 'test', + 'empty': None, + } + ) + assert result == { + 'limit': 10, + 'offset': 0, + 'flag': 1, + 'tags': 'tag1,tag2', + 'created_at': '2024-01-15T10:30:45.123Z', + 'name': 'test', + } + + +def test_is_retryable_error() -> None: + """Test _is_retryable_error correctly identifies retryable errors.""" + mock_response = Mock() + assert BaseHttpClient._is_retryable_error(InvalidResponseBodyError(mock_response)) + assert BaseHttpClient._is_retryable_error(impit.NetworkError('test')) + assert BaseHttpClient._is_retryable_error(impit.TimeoutException('test')) + assert BaseHttpClient._is_retryable_error(impit.RemoteProtocolError('test')) + + # Non-retryable errors + assert not BaseHttpClient._is_retryable_error(ValueError('test')) + assert not BaseHttpClient._is_retryable_error(RuntimeError('test')) + assert not BaseHttpClient._is_retryable_error(Exception('test')) + + +def test_prepare_request_call_basic() -> None: + """Test _prepare_request_call with basic parameters.""" + config = ClientConfig.from_user_params() + client = BaseHttpClient(config=config) + + headers, params, data = client._prepare_request_call() + assert headers == {} + assert params is None + assert data is None + + +def test_prepare_request_call_with_json() -> None: + """Test _prepare_request_call with JSON data.""" + config = ClientConfig.from_user_params() + client = BaseHttpClient(config=config) + + json_data = {'key': 'value', 'number': 42} + headers, _params, data = client._prepare_request_call(json=json_data) + + assert headers['Content-Type'] == 'application/json' + assert headers['Content-Encoding'] == 'gzip' + assert data is not None + assert isinstance(data, bytes) + + +def test_prepare_request_call_with_string_data() -> None: + """Test _prepare_request_call with string data.""" + config = ClientConfig.from_user_params() + client = BaseHttpClient(config=config) + + headers, _params, data = client._prepare_request_call(data='test string') + + assert headers['Content-Encoding'] == 'gzip' + assert isinstance(data, bytes) + + +def test_prepare_request_call_with_bytes_data() -> None: + """Test _prepare_request_call with bytes data.""" + config = ClientConfig.from_user_params() + client = BaseHttpClient(config=config) + + headers, _params, data = client._prepare_request_call(data=b'test bytes') + + assert headers['Content-Encoding'] == 'gzip' + assert isinstance(data, bytes) + + +def test_prepare_request_call_json_and_data_error() -> None: + """Test _prepare_request_call raises error when both json and data are provided.""" + config = ClientConfig.from_user_params() + client = BaseHttpClient(config=config) + + with pytest.raises(ValueError, match='Cannot pass both "json" and "data" parameters'): + client._prepare_request_call(json={'key': 'value'}, data='string') + + +def test_prepare_request_call_with_params() -> None: + """Test _prepare_request_call parses params correctly.""" + config = ClientConfig.from_user_params() + client = BaseHttpClient(config=config) + + _headers, params, _data = client._prepare_request_call(params={'limit': 10, 'flag': True}) + + assert params == {'limit': 10, 'flag': 1} + + +def test_build_url_with_params_none() -> None: + """Test _build_url_with_params with None params.""" + config = ClientConfig.from_user_params() + client = BaseHttpClient(config=config) + + url = client._build_url_with_params('https://api.test.com/endpoint') + assert url == 'https://api.test.com/endpoint' + + +def test_build_url_with_params_simple() -> None: + """Test _build_url_with_params with simple params.""" + config = ClientConfig.from_user_params() + client = BaseHttpClient(config=config) + + url = client._build_url_with_params('https://api.test.com/endpoint', {'key': 'value', 'limit': 10}) + assert 'key=value' in url + assert 'limit=10' in url + assert url.startswith('https://api.test.com/endpoint?') + + +def test_build_url_with_params_list() -> None: + """Test _build_url_with_params with list values.""" + config = ClientConfig.from_user_params() + client = BaseHttpClient(config=config) + + url = client._build_url_with_params('https://api.test.com/endpoint', {'tags': ['tag1', 'tag2', 'tag3']}) + assert 'tags=tag1' in url + assert 'tags=tag2' in url + assert 'tags=tag3' in url + + +def test_build_url_with_params_mixed() -> None: + """Test _build_url_with_params with mixed param types.""" + config = ClientConfig.from_user_params() + client = BaseHttpClient(config=config) + + url = client._build_url_with_params( + 'https://api.test.com/endpoint', {'limit': 10, 'tags': ['a', 'b'], 'name': 'test'} + ) + assert 'limit=10' in url + assert 'tags=a' in url + assert 'tags=b' in url + assert 'name=test' in url diff --git a/tests/unit/test_logging.py b/tests/unit/test_logging.py index 61f7113f..3c0c19dc 100644 --- a/tests/unit/test_logging.py +++ b/tests/unit/test_logging.py @@ -9,12 +9,12 @@ from unittest.mock import patch import pytest -from apify_shared.consts import ActorJobStatus from werkzeug import Request, Response from apify_client import ApifyClient, ApifyClientAsync from apify_client._logging import RedirectLogFormatter -from apify_client.clients.resource_clients.log import StatusMessageWatcher, StreamedLog +from apify_client._models import ActorJobStatus +from apify_client._resource_clients.log import StatusMessageWatcher, StreamedLog if TYPE_CHECKING: from collections.abc import Iterator @@ -82,6 +82,38 @@ def __init__(self) -> None: ('Final message', ActorJobStatus.SUCCEEDED, True), ] + def _create_minimal_run_data(self, message: str, status: ActorJobStatus, *, is_terminal: bool) -> dict: + """Create minimal valid Run data for testing.""" + return { + 'id': _MOCKED_RUN_ID, + 'actId': _MOCKED_ACTOR_ID, + 'userId': 'test_user_id', + 'startedAt': '2019-11-30T07:34:24.202Z', + 'finishedAt': '2019-12-12T09:30:12.202Z', + 'status': status.value, + 'statusMessage': message, + 'isStatusMessageTerminal': is_terminal, + 'meta': {'origin': 'WEB'}, + 'stats': { + 'restartCount': 0, + 'resurrectCount': 0, + 'computeUnits': 0.1, + }, + 'options': { + 'build': 'latest', + 'timeoutSecs': 300, + 'memoryMbytes': 1024, + 'diskMbytes': 2048, + }, + 'buildId': 'test_build_id', + 'generalAccess': 'RESTRICTED', + 'defaultKeyValueStoreId': 'test_kvs_id', + 'defaultDatasetId': 'test_dataset_id', + 'defaultRequestQueueId': 'test_rq_id', + 'buildNumber': '0.0.1', + 'containerUrl': 'https://test.runs.apify.net', + } + def get_response(self, _request: Request) -> Response: if self.current_status_index < len(self.statuses): message, status, is_terminal = self.statuses[self.current_status_index] @@ -98,15 +130,7 @@ def get_response(self, _request: Request) -> Response: self.current_status_index += 1 self.requests_for_current_status = 0 - status_data = { - 'data': { - 'id': _MOCKED_RUN_ID, - 'actId': _MOCKED_ACTOR_ID, - 'status': status, - 'statusMessage': message, - 'isStatusMessageTerminal': is_terminal, - } - } + status_data = {'data': self._create_minimal_run_data(message, status, is_terminal=is_terminal)} return Response(response=json.dumps(status_data), status=200, mimetype='application/json') @@ -141,12 +165,43 @@ def mock_api(httpserver: HTTPServer) -> None: # Add actor info endpoint httpserver.expect_request(f'/v2/acts/{_MOCKED_ACTOR_ID}', method='GET').respond_with_json( - {'data': {'name': _MOCKED_ACTOR_NAME}} + { + 'data': { + 'id': _MOCKED_ACTOR_ID, + 'userId': 'test_user_id', + 'name': _MOCKED_ACTOR_NAME, + 'username': 'test_user', + 'isPublic': False, + 'createdAt': '2019-07-08T11:27:57.401Z', + 'modifiedAt': '2019-07-08T14:01:05.546Z', + 'stats': { + 'totalBuilds': 0, + 'totalRuns': 0, + 'totalUsers': 0, + 'totalUsers7Days': 0, + 'totalUsers30Days': 0, + 'totalUsers90Days': 0, + 'totalMetamorphs': 0, + 'lastRunStartedAt': '2019-07-08T14:01:05.546Z', + }, + 'versions': [], + 'defaultRunOptions': { + 'build': 'latest', + 'timeoutSecs': 3600, + 'memoryMbytes': 2048, + }, + 'deploymentKey': 'test_key', + } + } ) # Add actor run creation endpoint httpserver.expect_request(f'/v2/acts/{_MOCKED_ACTOR_ID}/runs', method='POST').respond_with_json( - {'data': {'id': _MOCKED_RUN_ID}} + { + 'data': status_generator._create_minimal_run_data( + 'Initial message', ActorJobStatus.RUNNING, is_terminal=False + ), + } ) httpserver.expect_request( @@ -192,7 +247,7 @@ async def test_redirected_logs_async( run_client = ApifyClientAsync(token='mocked_token', api_url=api_url).run(run_id=_MOCKED_RUN_ID) - with patch('apify_client.clients.resource_clients.log.datetime') as mocked_datetime: + with patch('apify_client._resource_clients.log.datetime') as mocked_datetime: # Mock `now()` so that it has timestamp bigger than the first 3 logs mocked_datetime.now.return_value = datetime.fromisoformat('2025-05-13T07:24:14.132+00:00') streamed_log = await run_client.get_streamed_log(from_start=log_from_start) @@ -203,7 +258,7 @@ async def test_redirected_logs_async( with caplog.at_level(logging.DEBUG, logger=logger_name): async with streamed_log: # Do stuff while the log from the other Actor is being redirected to the logs. - await asyncio.sleep(1) + await asyncio.sleep(2) # Ensure logs are propagated assert {(record.message, record.levelno) for record in caplog.records} == set( @@ -232,7 +287,7 @@ def test_redirected_logs_sync( run_client = ApifyClient(token='mocked_token', api_url=api_url).run(run_id=_MOCKED_RUN_ID) - with patch('apify_client.clients.resource_clients.log.datetime') as mocked_datetime: + with patch('apify_client._resource_clients.log.datetime') as mocked_datetime: # Mock `now()` so that it has timestamp bigger than the first 3 logs mocked_datetime.now.return_value = datetime.fromisoformat('2025-05-13T07:24:14.132+00:00') streamed_log = run_client.get_streamed_log(from_start=log_from_start) @@ -242,7 +297,7 @@ def test_redirected_logs_sync( with caplog.at_level(logging.DEBUG, logger=logger_name), streamed_log: # Do stuff while the log from the other Actor is being redirected to the logs. - time.sleep(1) + time.sleep(2) # Ensure logs are propagated assert {(record.message, record.levelno) for record in caplog.records} == set( diff --git a/tests/unit/test_statistics.py b/tests/unit/test_statistics.py index 53859ce1..69fbc3c4 100644 --- a/tests/unit/test_statistics.py +++ b/tests/unit/test_statistics.py @@ -1,6 +1,6 @@ import pytest -from apify_client._statistics import Statistics +from apify_client._statistics import ClientStatistics @pytest.mark.parametrize( @@ -16,7 +16,7 @@ ) def test_add_rate_limit_error(attempts: list[int], expected_errors: list[int]) -> None: """Test that add_rate_limit_error correctly tracks errors for different attempt sequences.""" - stats = Statistics() + stats = ClientStatistics() for attempt in attempts: stats.add_rate_limit_error(attempt) assert stats.rate_limit_errors == expected_errors @@ -24,14 +24,14 @@ def test_add_rate_limit_error(attempts: list[int], expected_errors: list[int]) - def test_add_rate_limit_error_invalid_attempt() -> None: """Test that add_rate_limit_error raises ValueError for invalid attempt.""" - stats = Statistics() + stats = ClientStatistics() with pytest.raises(ValueError, match=r'Attempt must be greater than 0'): stats.add_rate_limit_error(0) def test_statistics_initial_state() -> None: """Test initial state of Statistics instance.""" - stats = Statistics() + stats = ClientStatistics() assert stats.calls == 0 assert stats.requests == 0 assert stats.rate_limit_errors == {} @@ -39,6 +39,82 @@ def test_statistics_initial_state() -> None: def test_add_rate_limit_error_type_validation() -> None: """Test type validation in add_rate_limit_error.""" - stats = Statistics() + stats = ClientStatistics() with pytest.raises(TypeError): stats.add_rate_limit_error('1') # ty: ignore[invalid-argument-type] + + +def test_statistics_calls_and_requests_increment() -> None: + """Test that calls and requests can be incremented.""" + stats = ClientStatistics() + + stats.calls += 1 + assert stats.calls == 1 + + stats.requests += 1 + assert stats.requests == 1 + + stats.calls += 5 + stats.requests += 10 + assert stats.calls == 6 + assert stats.requests == 11 + + +def test_rate_limit_errors_accumulation() -> None: + """Test that rate limit errors accumulate correctly.""" + stats = ClientStatistics() + + # Add errors to different attempts + stats.add_rate_limit_error(1) + stats.add_rate_limit_error(2) + stats.add_rate_limit_error(3) + + assert stats.rate_limit_errors[0] == 1 + assert stats.rate_limit_errors[1] == 1 + assert stats.rate_limit_errors[2] == 1 + + # Add more errors to same attempts + stats.add_rate_limit_error(1) + stats.add_rate_limit_error(1) + stats.add_rate_limit_error(2) + + assert stats.rate_limit_errors[0] == 3 + assert stats.rate_limit_errors[1] == 2 + assert stats.rate_limit_errors[2] == 1 + + +def test_rate_limit_errors_dict_behavior() -> None: + """Test that rate_limit_errors behaves like a defaultdict.""" + stats = ClientStatistics() + + # Accessing non-existent key should not raise error (defaultdict behavior) + assert stats.rate_limit_errors.get(999, 0) == 0 + + # Adding to high attempt numbers should work + stats.add_rate_limit_error(100) + assert stats.rate_limit_errors[99] == 1 + + +def test_statistics_independent_instances() -> None: + """Test that different Statistics instances are independent.""" + stats1 = ClientStatistics() + stats2 = ClientStatistics() + + stats1.calls = 10 + stats1.requests = 20 + stats1.add_rate_limit_error(1) + + assert stats2.calls == 0 + assert stats2.requests == 0 + assert stats2.rate_limit_errors == {} + + +def test_add_rate_limit_error_large_attempt() -> None: + """Test add_rate_limit_error with large attempt numbers.""" + stats = ClientStatistics() + + stats.add_rate_limit_error(1000) + assert stats.rate_limit_errors[999] == 1 + + stats.add_rate_limit_error(10000) + assert stats.rate_limit_errors[9999] == 1 diff --git a/tests/unit/test_utils.py b/tests/unit/test_utils.py index 95f359f8..ffa20ba2 100644 --- a/tests/unit/test_utils.py +++ b/tests/unit/test_utils.py @@ -1,142 +1,35 @@ -import time -from collections.abc import Callable -from typing import Any +import io +from enum import Enum +from http import HTTPStatus +from unittest.mock import Mock import pytest -from apify_shared.consts import WebhookEventType +from apify_client._consts import WebhookEventType from apify_client._utils import ( + catch_not_found_or_throw, + create_hmac_signature, + create_storage_content_signature, + encode_base62, + encode_key_value_store_record_value, encode_webhook_list_to_base64, - pluck_data, - retry_with_exp_backoff, - retry_with_exp_backoff_async, + enum_to_value, + filter_none_values, + response_to_dict, + response_to_list, to_safe_id, ) +from apify_client.errors import ApifyApiError -def test__to_safe_id() -> None: +def test_to_safe_id() -> None: assert to_safe_id('abc') == 'abc' assert to_safe_id('abc/def') == 'abc~def' assert to_safe_id('abc~def') == 'abc~def' + assert to_safe_id('user/resource/extra') == 'user~resource~extra' -def test_pluck_data() -> None: - # works correctly when data is present - assert pluck_data({'data': {}}) == {} - assert pluck_data({'a': 'b', 'data': {'b': 'c'}}) == {'b': 'c'} - - # throws the right error when it is not - with pytest.raises(ValueError, match=r'The "data" property is missing in the response.'): - pluck_data({'a': 'b'}) - with pytest.raises(ValueError, match=r'The "data" property is missing in the response.'): - pluck_data(None) - with pytest.raises(ValueError, match=r'The "data" property is missing in the response.'): - pluck_data('{"a": "b"}') - - -def test__retry_with_exp_backoff() -> None: - attempt_counter = 0 - - class RetryableError(Exception): - pass - - class NonRetryableError(Exception): - pass - - def returns_on_fifth_attempt(_stop_retrying: Callable, attempt: int) -> Any: - nonlocal attempt_counter - attempt_counter += 1 - - if attempt == 5: - return 'SUCCESS' - raise RetryableError - - def bails_on_third_attempt(stop_retrying: Callable, attempt: int) -> Any: - nonlocal attempt_counter - attempt_counter += 1 - - if attempt == 3: - stop_retrying() - raise NonRetryableError - else: # noqa: RET506 - raise RetryableError - - # Returns the correct result after the correct time (should take 100 + 200 + 400 + 800 = 1500 ms) - start = time.time() - result = retry_with_exp_backoff( - returns_on_fifth_attempt, backoff_base_millis=100, backoff_factor=2, random_factor=0 - ) - elapsed_time_seconds = time.time() - start - assert result == 'SUCCESS' - assert attempt_counter == 5 - assert elapsed_time_seconds > 1.4 - assert elapsed_time_seconds < 2.0 - - # Stops retrying when failed for max_retries times - attempt_counter = 0 - with pytest.raises(RetryableError): - retry_with_exp_backoff(returns_on_fifth_attempt, max_retries=3, backoff_base_millis=1) - assert attempt_counter == 4 - - # Bails when the bail function is called - attempt_counter = 0 - with pytest.raises(NonRetryableError): - retry_with_exp_backoff(bails_on_third_attempt, backoff_base_millis=1) - assert attempt_counter == 3 - - -async def test__retry_with_exp_backoff_async() -> None: - attempt_counter = 0 - - class RetryableError(Exception): - pass - - class NonRetryableError(Exception): - pass - - async def returns_on_fifth_attempt(_stop_retrying: Callable, attempt: int) -> Any: - nonlocal attempt_counter - attempt_counter += 1 - - if attempt == 5: - return 'SUCCESS' - raise RetryableError - - async def bails_on_third_attempt(stop_retrying: Callable, attempt: int) -> Any: - nonlocal attempt_counter - attempt_counter += 1 - - if attempt == 3: - stop_retrying() - raise NonRetryableError - else: # noqa: RET506 - raise RetryableError - - # Returns the correct result after the correct time (should take 100 + 200 + 400 + 800 = 1500 ms) - start = time.time() - result = await retry_with_exp_backoff_async( - returns_on_fifth_attempt, backoff_base_millis=100, backoff_factor=2, random_factor=0 - ) - elapsed_time_seconds = time.time() - start - assert result == 'SUCCESS' - assert attempt_counter == 5 - assert elapsed_time_seconds > 1.4 - assert elapsed_time_seconds < 2.0 - - # Stops retrying when failed for max_retries times - attempt_counter = 0 - with pytest.raises(RetryableError): - await retry_with_exp_backoff_async(returns_on_fifth_attempt, max_retries=3, backoff_base_millis=1) - assert attempt_counter == 4 - - # Bails when the bail function is called - attempt_counter = 0 - with pytest.raises(NonRetryableError): - await retry_with_exp_backoff_async(bails_on_third_attempt, backoff_base_millis=1) - assert attempt_counter == 3 - - -def test__encode_webhook_list_to_base64() -> None: +def test_encode_webhook_list_to_base64() -> None: assert encode_webhook_list_to_base64([]) == 'W10=' assert ( encode_webhook_list_to_base64( @@ -154,3 +47,203 @@ def test__encode_webhook_list_to_base64() -> None: ) == 'W3siZXZlbnRUeXBlcyI6IFsiQUNUT1IuUlVOLkNSRUFURUQiXSwgInJlcXVlc3RVcmwiOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9ydW4tY3JlYXRlZCJ9LCB7ImV2ZW50VHlwZXMiOiBbIkFDVE9SLlJVTi5TVUNDRUVERUQiXSwgInJlcXVlc3RVcmwiOiAiaHR0cHM6Ly9leGFtcGxlLmNvbS9ydW4tc3VjY2VlZGVkIiwgInBheWxvYWRUZW1wbGF0ZSI6ICJ7XCJoZWxsb1wiOiBcIndvcmxkXCIsIFwicmVzb3VyY2VcIjp7e3Jlc291cmNlfX19In1d' # noqa: E501 ) + + +def test_catch_not_found_or_throw() -> None: + """Test that catch_not_found_or_throw suppresses 404 errors correctly.""" + # Mock response for 404 Not Found + mock_response = Mock() + mock_response.status_code = HTTPStatus.NOT_FOUND + mock_response.text = '{"error":{"type":"record-not-found"}}' + + # Should not raise for record-not-found + error_404_record = ApifyApiError(mock_response, 1) + error_404_record.type = 'record-not-found' + catch_not_found_or_throw(error_404_record) + + # Should not raise for record-or-token-not-found + error_404_token = ApifyApiError(mock_response, 1) + error_404_token.type = 'record-or-token-not-found' + catch_not_found_or_throw(error_404_token) + + # Should raise for other error types with 404 + error_404_other = ApifyApiError(mock_response, 1) + error_404_other.type = 'some-other-error' + with pytest.raises(ApifyApiError): + catch_not_found_or_throw(error_404_other) + + # Should raise for non-404 status codes + mock_response_500 = Mock() + mock_response_500.status_code = HTTPStatus.INTERNAL_SERVER_ERROR + mock_response_500.text = '{"error":{"type":"record-not-found"}}' + error_500 = ApifyApiError(mock_response_500, 1) + error_500.type = 'record-not-found' + with pytest.raises(ApifyApiError): + catch_not_found_or_throw(error_500) + + +def test_filter_none_values() -> None: + """Test filtering None values from dictionaries.""" + # Simple case + assert filter_none_values({'a': 1, 'b': None, 'c': 3}) == {'a': 1, 'c': 3} + + # Nested dictionaries + assert filter_none_values({'a': {'b': None, 'c': 2}, 'd': None}) == {'a': {'c': 2}} + + # Deep nesting + assert filter_none_values({'a': {'b': {'c': None, 'd': 4}}}) == {'a': {'b': {'d': 4}}} + + # Empty dict after filtering + assert filter_none_values({'a': None, 'b': None}) == {} + + # Remove empty dicts + assert filter_none_values({'a': {'b': None}, 'c': 3}, remove_empty_dicts=True) == {'c': 3} + + # Don't remove empty dicts by default + assert filter_none_values({'a': {'b': None}, 'c': 3}) == {'a': {}, 'c': 3} + + # Keep zero values + assert filter_none_values({'a': 0, 'b': '', 'c': False}) == {'a': 0, 'b': '', 'c': False} + + +def test_encode_key_value_store_record_value() -> None: + """Test encoding of key-value store record values.""" + # Dictionary should be encoded as JSON + value, content_type = encode_key_value_store_record_value({'key': 'value'}) + assert b'"key"' in value + assert b'"value"' in value + assert content_type == 'application/json; charset=utf-8' + + # String should be text/plain + value, content_type = encode_key_value_store_record_value('hello') + assert value == 'hello' + assert content_type == 'text/plain; charset=utf-8' + + # Bytes should be octet-stream + value, content_type = encode_key_value_store_record_value(b'binary data') + assert value == b'binary data' + assert content_type == 'application/octet-stream' + + # Custom content type should be preserved + value, content_type = encode_key_value_store_record_value('hello', 'text/html') + assert value == 'hello' + assert content_type == 'text/html' + + # BytesIO should be octet-stream + buffer = io.BytesIO(b'buffer data') + value, content_type = encode_key_value_store_record_value(buffer) + assert value == buffer + assert content_type == 'application/octet-stream' + + +def test_enum_to_value() -> None: + """Test enum to value conversion.""" + + class TestEnum(Enum): + VALUE1 = 'val1' + VALUE2 = 42 + + assert enum_to_value(TestEnum.VALUE1) == 'val1' + assert enum_to_value(TestEnum.VALUE2) == 42 + assert enum_to_value('not_an_enum') == 'not_an_enum' + assert enum_to_value(123) == 123 + assert enum_to_value(None) is None + + +def test_response_to_dict() -> None: + """Test parsing response as dictionary.""" + mock_response = Mock() + mock_response.json.return_value = {'key': 'value'} + + result = response_to_dict(mock_response) + assert result == {'key': 'value'} + + # Should raise for non-dict responses + mock_response.json.return_value = ['list', 'response'] + with pytest.raises(ValueError, match='The response is not a dictionary'): + response_to_dict(mock_response) + + mock_response.json.return_value = 'string' + with pytest.raises(ValueError, match='The response is not a dictionary'): + response_to_dict(mock_response) + + +def test_response_to_list() -> None: + """Test parsing response as list.""" + mock_response = Mock() + mock_response.json.return_value = ['item1', 'item2'] + + result = response_to_list(mock_response) + assert result == ['item1', 'item2'] + + # Should raise for non-list responses + mock_response.json.return_value = {'dict': 'response'} + with pytest.raises(ValueError, match='The response is not a list'): + response_to_list(mock_response) + + mock_response.json.return_value = 'string' + with pytest.raises(ValueError, match='The response is not a list'): + response_to_list(mock_response) + + +def test_encode_base62() -> None: + """Test base62 encoding. + + charset = string.digits + string.ascii_letters + So: 0-9 (0-9), a-z (10-35), A-Z (36-61) + """ + assert encode_base62(0) == '0' + assert encode_base62(1) == '1' + assert encode_base62(9) == '9' + assert encode_base62(10) == 'a' + assert encode_base62(35) == 'z' + assert encode_base62(36) == 'A' + assert encode_base62(61) == 'Z' + assert encode_base62(62) == '10' + assert encode_base62(100) == '1C' + assert encode_base62(123456) == 'w7e' + + +def test_create_hmac_signature() -> None: + """Test HMAC signature creation.""" + # Test with known values + signature = create_hmac_signature('secret_key', 'test_message') + assert isinstance(signature, str) + assert len(signature) > 0 + + # Same inputs should produce same output + signature2 = create_hmac_signature('secret_key', 'test_message') + assert signature == signature2 + + # Different inputs should produce different output + signature3 = create_hmac_signature('different_key', 'test_message') + assert signature != signature3 + + signature4 = create_hmac_signature('secret_key', 'different_message') + assert signature != signature4 + + +def test_create_storage_content_signature() -> None: + """Test storage content signature creation.""" + # Non-expiring signature + signature = create_storage_content_signature('resource_123', 'secret_key') + assert isinstance(signature, str) + assert len(signature) > 0 + + # Expiring signature + signature_expiring = create_storage_content_signature('resource_123', 'secret_key', expires_in_millis=60000) + assert isinstance(signature_expiring, str) + assert len(signature_expiring) > 0 + assert signature != signature_expiring # Different because of expiration + + # Different resource IDs produce different signatures + signature2 = create_storage_content_signature('resource_456', 'secret_key') + assert signature != signature2 + + # Same inputs should produce same output (for non-expiring) + signature3 = create_storage_content_signature('resource_123', 'secret_key') + assert signature == signature3 + + # Test with version parameter + signature_v1 = create_storage_content_signature('resource_123', 'secret_key', version=1) + assert signature != signature_v1 diff --git a/uv.lock b/uv.lock index d6fd0385..363ae10d 100644 --- a/uv.lock +++ b/uv.lock @@ -2,20 +2,44 @@ version = 1 revision = 3 requires-python = ">=3.10" +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "anyio" +version = "4.12.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" }, +] + [[package]] name = "apify-client" version = "2.4.1" source = { editable = "." } dependencies = [ - { name = "apify-shared" }, { name = "colorama" }, { name = "impit" }, { name = "more-itertools" }, + { name = "pydantic", extra = ["email"] }, ] [package.dev-dependencies] dev = [ { name = "black" }, + { name = "datamodel-code-generator", extra = ["http", "ruff"] }, { name = "dycw-pytest-only" }, { name = "griffe" }, { name = "poethepoet" }, @@ -37,15 +61,16 @@ dev = [ [package.metadata] requires-dist = [ - { name = "apify-shared", specifier = ">=2.1.0,<3.0.0" }, { name = "colorama", specifier = ">=0.4.0" }, { name = "impit", specifier = ">=0.9.2" }, { name = "more-itertools", specifier = ">=10.0.0" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.11.0" }, ] [package.metadata.requires-dev] dev = [ { name = "black", specifier = ">=24.3.0" }, + { name = "datamodel-code-generator", extras = ["http", "ruff"], specifier = "<1.0.0" }, { name = "dycw-pytest-only", specifier = "<3.0.0" }, { name = "griffe" }, { name = "poethepoet", specifier = "<1.0.0" }, @@ -66,21 +91,21 @@ dev = [ ] [[package]] -name = "apify-shared" -version = "2.2.0" +name = "appdirs" +version = "1.4.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/88/8833a8bba9044ce134bb2e57fbb626f1ddbeecac964bc2e2b652a50fadd1/apify_shared-2.2.0.tar.gz", hash = "sha256:ad48a96084e3c38faa1bac723a47929a1bb2c771544da2f0cb503eabdecfc79a", size = 45534, upload-time = "2026-01-15T10:17:14.592Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/75/7c/9607852e2bb324fa40a5b967e162dea1b3c76b429cf90b602e4a202c101a/apify_shared-2.2.0-py3-none-any.whl", hash = "sha256:667d4d00ac3cf8091702640547387ac5c72a1df402bbb3923f7a401bc25d9d50", size = 16408, upload-time = "2026-01-15T10:17:13.103Z" }, + { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, ] [[package]] -name = "appdirs" -version = "1.4.4" +name = "argcomplete" +version = "3.6.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/d8/05696357e0311f5b5c316d7b95f46c669dd9c15aaeecbb48c7d0aeb88c40/appdirs-1.4.4.tar.gz", hash = "sha256:7d5d0167b2b1ba821647616af46a749d1c653740dd0d2415100fe26e27afdf41", size = 13470, upload-time = "2020-05-11T07:59:51.037Z" } +sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/00/2344469e2084fb287c2e0b57b72910309874c3245463acd6cf5e3db69324/appdirs-1.4.4-py2.py3-none-any.whl", hash = "sha256:a841dacd6b99318a741b166adb07e19ee71a274450e68237b4650ca1055ab128", size = 9566, upload-time = "2020-05-11T07:59:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" }, ] [[package]] @@ -106,7 +131,7 @@ wheels = [ [[package]] name = "black" -version = "26.1.0" +version = "25.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -118,34 +143,34 @@ dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/13/88/560b11e521c522440af991d46848a2bde64b5f7202ec14e1f46f9509d328/black-26.1.0.tar.gz", hash = "sha256:d294ac3340eef9c9eb5d29288e96dc719ff269a88e27b396340459dd85da4c58", size = 658785, upload-time = "2026-01-18T04:50:11.993Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/51/1b/523329e713f965ad0ea2b7a047eeb003007792a0353622ac7a8cb2ee6fef/black-26.1.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ca699710dece84e3ebf6e92ee15f5b8f72870ef984bf944a57a777a48357c168", size = 1849661, upload-time = "2026-01-18T04:59:12.425Z" }, - { url = "https://files.pythonhosted.org/packages/14/82/94c0640f7285fa71c2f32879f23e609dd2aa39ba2641f395487f24a578e7/black-26.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5e8e75dabb6eb83d064b0db46392b25cabb6e784ea624219736e8985a6b3675d", size = 1689065, upload-time = "2026-01-18T04:59:13.993Z" }, - { url = "https://files.pythonhosted.org/packages/f0/78/474373cbd798f9291ed8f7107056e343fd39fef42de4a51c7fd0d360840c/black-26.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eb07665d9a907a1a645ee41a0df8a25ffac8ad9c26cdb557b7b88eeeeec934e0", size = 1751502, upload-time = "2026-01-18T04:59:15.971Z" }, - { url = "https://files.pythonhosted.org/packages/29/89/59d0e350123f97bc32c27c4d79563432d7f3530dca2bff64d855c178af8b/black-26.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:7ed300200918147c963c87700ccf9966dceaefbbb7277450a8d646fc5646bf24", size = 1400102, upload-time = "2026-01-18T04:59:17.8Z" }, - { url = "https://files.pythonhosted.org/packages/e1/bc/5d866c7ae1c9d67d308f83af5462ca7046760158bbf142502bad8f22b3a1/black-26.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:c5b7713daea9bf943f79f8c3b46f361cc5229e0e604dcef6a8bb6d1c37d9df89", size = 1207038, upload-time = "2026-01-18T04:59:19.543Z" }, - { url = "https://files.pythonhosted.org/packages/30/83/f05f22ff13756e1a8ce7891db517dbc06200796a16326258268f4658a745/black-26.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3cee1487a9e4c640dc7467aaa543d6c0097c391dc8ac74eb313f2fbf9d7a7cb5", size = 1831956, upload-time = "2026-01-18T04:59:21.38Z" }, - { url = "https://files.pythonhosted.org/packages/7d/f2/b2c570550e39bedc157715e43927360312d6dd677eed2cc149a802577491/black-26.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d62d14ca31c92adf561ebb2e5f2741bf8dea28aef6deb400d49cca011d186c68", size = 1672499, upload-time = "2026-01-18T04:59:23.257Z" }, - { url = "https://files.pythonhosted.org/packages/7a/d7/990d6a94dc9e169f61374b1c3d4f4dd3037e93c2cc12b6f3b12bc663aa7b/black-26.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb1dafbbaa3b1ee8b4550a84425aac8874e5f390200f5502cf3aee4a2acb2f14", size = 1735431, upload-time = "2026-01-18T04:59:24.729Z" }, - { url = "https://files.pythonhosted.org/packages/36/1c/cbd7bae7dd3cb315dfe6eeca802bb56662cc92b89af272e014d98c1f2286/black-26.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:101540cb2a77c680f4f80e628ae98bd2bd8812fb9d72ade4f8995c5ff019e82c", size = 1400468, upload-time = "2026-01-18T04:59:27.381Z" }, - { url = "https://files.pythonhosted.org/packages/59/b1/9fe6132bb2d0d1f7094613320b56297a108ae19ecf3041d9678aec381b37/black-26.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:6f3977a16e347f1b115662be07daa93137259c711e526402aa444d7a88fdc9d4", size = 1207332, upload-time = "2026-01-18T04:59:28.711Z" }, - { url = "https://files.pythonhosted.org/packages/f5/13/710298938a61f0f54cdb4d1c0baeb672c01ff0358712eddaf29f76d32a0b/black-26.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6eeca41e70b5f5c84f2f913af857cf2ce17410847e1d54642e658e078da6544f", size = 1878189, upload-time = "2026-01-18T04:59:30.682Z" }, - { url = "https://files.pythonhosted.org/packages/79/a6/5179beaa57e5dbd2ec9f1c64016214057b4265647c62125aa6aeffb05392/black-26.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:dd39eef053e58e60204f2cdf059e2442e2eb08f15989eefe259870f89614c8b6", size = 1700178, upload-time = "2026-01-18T04:59:32.387Z" }, - { url = "https://files.pythonhosted.org/packages/8c/04/c96f79d7b93e8f09d9298b333ca0d31cd9b2ee6c46c274fd0f531de9dc61/black-26.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9459ad0d6cd483eacad4c6566b0f8e42af5e8b583cee917d90ffaa3778420a0a", size = 1777029, upload-time = "2026-01-18T04:59:33.767Z" }, - { url = "https://files.pythonhosted.org/packages/49/f9/71c161c4c7aa18bdda3776b66ac2dc07aed62053c7c0ff8bbda8c2624fe2/black-26.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:a19915ec61f3a8746e8b10adbac4a577c6ba9851fa4a9e9fbfbcf319887a5791", size = 1406466, upload-time = "2026-01-18T04:59:35.177Z" }, - { url = "https://files.pythonhosted.org/packages/4a/8b/a7b0f974e473b159d0ac1b6bcefffeb6bec465898a516ee5cc989503cbc7/black-26.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:643d27fb5facc167c0b1b59d0315f2674a6e950341aed0fc05cf307d22bf4954", size = 1216393, upload-time = "2026-01-18T04:59:37.18Z" }, - { url = "https://files.pythonhosted.org/packages/79/04/fa2f4784f7237279332aa735cdfd5ae2e7730db0072fb2041dadda9ae551/black-26.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ba1d768fbfb6930fc93b0ecc32a43d8861ded16f47a40f14afa9bb04ab93d304", size = 1877781, upload-time = "2026-01-18T04:59:39.054Z" }, - { url = "https://files.pythonhosted.org/packages/cf/ad/5a131b01acc0e5336740a039628c0ab69d60cf09a2c87a4ec49f5826acda/black-26.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2b807c240b64609cb0e80d2200a35b23c7df82259f80bef1b2c96eb422b4aac9", size = 1699670, upload-time = "2026-01-18T04:59:41.005Z" }, - { url = "https://files.pythonhosted.org/packages/da/7c/b05f22964316a52ab6b4265bcd52c0ad2c30d7ca6bd3d0637e438fc32d6e/black-26.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1de0f7d01cc894066a1153b738145b194414cc6eeaad8ef4397ac9abacf40f6b", size = 1775212, upload-time = "2026-01-18T04:59:42.545Z" }, - { url = "https://files.pythonhosted.org/packages/a6/a3/e8d1526bea0446e040193185353920a9506eab60a7d8beb062029129c7d2/black-26.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:91a68ae46bf07868963671e4d05611b179c2313301bd756a89ad4e3b3db2325b", size = 1409953, upload-time = "2026-01-18T04:59:44.357Z" }, - { url = "https://files.pythonhosted.org/packages/c7/5a/d62ebf4d8f5e3a1daa54adaab94c107b57be1b1a2f115a0249b41931e188/black-26.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:be5e2fe860b9bd9edbf676d5b60a9282994c03fbbd40fe8f5e75d194f96064ca", size = 1217707, upload-time = "2026-01-18T04:59:45.719Z" }, - { url = "https://files.pythonhosted.org/packages/6a/83/be35a175aacfce4b05584ac415fd317dd6c24e93a0af2dcedce0f686f5d8/black-26.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9dc8c71656a79ca49b8d3e2ce8103210c9481c57798b48deeb3a8bb02db5f115", size = 1871864, upload-time = "2026-01-18T04:59:47.586Z" }, - { url = "https://files.pythonhosted.org/packages/a5/f5/d33696c099450b1274d925a42b7a030cd3ea1f56d72e5ca8bbed5f52759c/black-26.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b22b3810451abe359a964cc88121d57f7bce482b53a066de0f1584988ca36e79", size = 1701009, upload-time = "2026-01-18T04:59:49.443Z" }, - { url = "https://files.pythonhosted.org/packages/1b/87/670dd888c537acb53a863bc15abbd85b22b429237d9de1b77c0ed6b79c42/black-26.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53c62883b3f999f14e5d30b5a79bd437236658ad45b2f853906c7cbe79de00af", size = 1767806, upload-time = "2026-01-18T04:59:50.769Z" }, - { url = "https://files.pythonhosted.org/packages/fe/9c/cd3deb79bfec5bcf30f9d2100ffeec63eecce826eb63e3961708b9431ff1/black-26.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:f016baaadc423dc960cdddf9acae679e71ee02c4c341f78f3179d7e4819c095f", size = 1433217, upload-time = "2026-01-18T04:59:52.218Z" }, - { url = "https://files.pythonhosted.org/packages/4e/29/f3be41a1cf502a283506f40f5d27203249d181f7a1a2abce1c6ce188035a/black-26.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:66912475200b67ef5a0ab665011964bf924745103f51977a78b4fb92a9fc1bf0", size = 1245773, upload-time = "2026-01-18T04:59:54.457Z" }, - { url = "https://files.pythonhosted.org/packages/e4/3d/51bdb3ecbfadfaf825ec0c75e1de6077422b4afa2091c6c9ba34fbfc0c2d/black-26.1.0-py3-none-any.whl", hash = "sha256:1054e8e47ebd686e078c0bb0eaf31e6ce69c966058d122f2c0c950311f9f3ede", size = 204010, upload-time = "2026-01-18T04:50:09.978Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/c4/d9/07b458a3f1c525ac392b5edc6b191ff140b596f9d77092429417a54e249d/black-25.12.0.tar.gz", hash = "sha256:8d3dd9cea14bff7ddc0eb243c811cdb1a011ebb4800a5f0335a01a68654796a7", size = 659264, upload-time = "2025-12-08T01:40:52.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/d5/8d3145999d380e5d09bb00b0f7024bf0a8ccb5c07b5648e9295f02ec1d98/black-25.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f85ba1ad15d446756b4ab5f3044731bf68b777f8f9ac9cdabd2425b97cd9c4e8", size = 1895720, upload-time = "2025-12-08T01:46:58.197Z" }, + { url = "https://files.pythonhosted.org/packages/06/97/7acc85c4add41098f4f076b21e3e4e383ad6ed0a3da26b2c89627241fc11/black-25.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:546eecfe9a3a6b46f9d69d8a642585a6eaf348bcbbc4d87a19635570e02d9f4a", size = 1727193, upload-time = "2025-12-08T01:52:26.674Z" }, + { url = "https://files.pythonhosted.org/packages/24/f0/fdf0eb8ba907ddeb62255227d29d349e8256ef03558fbcadfbc26ecfe3b2/black-25.12.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17dcc893da8d73d8f74a596f64b7c98ef5239c2cd2b053c0f25912c4494bf9ea", size = 1774506, upload-time = "2025-12-08T01:46:25.721Z" }, + { url = "https://files.pythonhosted.org/packages/e4/f5/9203a78efe00d13336786b133c6180a9303d46908a9aa72d1104ca214222/black-25.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:09524b0e6af8ba7a3ffabdfc7a9922fb9adef60fed008c7cd2fc01f3048e6e6f", size = 1416085, upload-time = "2025-12-08T01:46:06.073Z" }, + { url = "https://files.pythonhosted.org/packages/ba/cc/7a6090e6b081c3316282c05c546e76affdce7bf7a3b7d2c3a2a69438bd01/black-25.12.0-cp310-cp310-win_arm64.whl", hash = "sha256:b162653ed89eb942758efeb29d5e333ca5bb90e5130216f8369857db5955a7da", size = 1226038, upload-time = "2025-12-08T01:45:29.388Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/7ac0d0e1e0612788dbc48e62aef8a8e8feffac7eb3d787db4e43b8462fa8/black-25.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d0cfa263e85caea2cff57d8f917f9f51adae8e20b610e2b23de35b5b11ce691a", size = 1877003, upload-time = "2025-12-08T01:43:29.967Z" }, + { url = "https://files.pythonhosted.org/packages/e8/dd/a237e9f565f3617a88b49284b59cbca2a4f56ebe68676c1aad0ce36a54a7/black-25.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1a2f578ae20c19c50a382286ba78bfbeafdf788579b053d8e4980afb079ab9be", size = 1712639, upload-time = "2025-12-08T01:52:46.756Z" }, + { url = "https://files.pythonhosted.org/packages/12/80/e187079df1ea4c12a0c63282ddd8b81d5107db6d642f7d7b75a6bcd6fc21/black-25.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e1b65634b0e471d07ff86ec338819e2ef860689859ef4501ab7ac290431f9b", size = 1758143, upload-time = "2025-12-08T01:45:29.137Z" }, + { url = "https://files.pythonhosted.org/packages/93/b5/3096ccee4f29dc2c3aac57274326c4d2d929a77e629f695f544e159bfae4/black-25.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:a3fa71e3b8dd9f7c6ac4d818345237dfb4175ed3bf37cd5a581dbc4c034f1ec5", size = 1420698, upload-time = "2025-12-08T01:45:53.379Z" }, + { url = "https://files.pythonhosted.org/packages/7e/39/f81c0ffbc25ffbe61c7d0385bf277e62ffc3e52f5ee668d7369d9854fadf/black-25.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:51e267458f7e650afed8445dc7edb3187143003d52a1b710c7321aef22aa9655", size = 1229317, upload-time = "2025-12-08T01:46:35.606Z" }, + { url = "https://files.pythonhosted.org/packages/d1/bd/26083f805115db17fda9877b3c7321d08c647df39d0df4c4ca8f8450593e/black-25.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:31f96b7c98c1ddaeb07dc0f56c652e25bdedaac76d5b68a059d998b57c55594a", size = 1924178, upload-time = "2025-12-08T01:49:51.048Z" }, + { url = "https://files.pythonhosted.org/packages/89/6b/ea00d6651561e2bdd9231c4177f4f2ae19cc13a0b0574f47602a7519b6ca/black-25.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:05dd459a19e218078a1f98178c13f861fe6a9a5f88fc969ca4d9b49eb1809783", size = 1742643, upload-time = "2025-12-08T01:49:59.09Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f3/360fa4182e36e9875fabcf3a9717db9d27a8d11870f21cff97725c54f35b/black-25.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1f68c5eff61f226934be6b5b80296cf6939e5d2f0c2f7d543ea08b204bfaf59", size = 1800158, upload-time = "2025-12-08T01:44:27.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/08/2c64830cb6616278067e040acca21d4f79727b23077633953081c9445d61/black-25.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:274f940c147ddab4442d316b27f9e332ca586d39c85ecf59ebdea82cc9ee8892", size = 1426197, upload-time = "2025-12-08T01:45:51.198Z" }, + { url = "https://files.pythonhosted.org/packages/d4/60/a93f55fd9b9816b7432cf6842f0e3000fdd5b7869492a04b9011a133ee37/black-25.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:169506ba91ef21e2e0591563deda7f00030cb466e747c4b09cb0a9dae5db2f43", size = 1237266, upload-time = "2025-12-08T01:45:10.556Z" }, + { url = "https://files.pythonhosted.org/packages/c8/52/c551e36bc95495d2aa1a37d50566267aa47608c81a53f91daa809e03293f/black-25.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a05ddeb656534c3e27a05a29196c962877c83fa5503db89e68857d1161ad08a5", size = 1923809, upload-time = "2025-12-08T01:46:55.126Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f7/aac9b014140ee56d247e707af8db0aae2e9efc28d4a8aba92d0abd7ae9d1/black-25.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9ec77439ef3e34896995503865a85732c94396edcc739f302c5673a2315e1e7f", size = 1742384, upload-time = "2025-12-08T01:49:37.022Z" }, + { url = "https://files.pythonhosted.org/packages/74/98/38aaa018b2ab06a863974c12b14a6266badc192b20603a81b738c47e902e/black-25.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e509c858adf63aa61d908061b52e580c40eae0dfa72415fa47ac01b12e29baf", size = 1798761, upload-time = "2025-12-08T01:46:05.386Z" }, + { url = "https://files.pythonhosted.org/packages/16/3a/a8ac542125f61574a3f015b521ca83b47321ed19bb63fe6d7560f348bfe1/black-25.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:252678f07f5bac4ff0d0e9b261fbb029fa530cfa206d0a636a34ab445ef8ca9d", size = 1429180, upload-time = "2025-12-08T01:45:34.903Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2d/bdc466a3db9145e946762d52cd55b1385509d9f9004fec1c97bdc8debbfb/black-25.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc5b1c09fe3c931ddd20ee548511c64ebf964ada7e6f0763d443947fd1c603ce", size = 1239350, upload-time = "2025-12-08T01:46:09.458Z" }, + { url = "https://files.pythonhosted.org/packages/35/46/1d8f2542210c502e2ae1060b2e09e47af6a5e5963cb78e22ec1a11170b28/black-25.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0a0953b134f9335c2434864a643c842c44fba562155c738a2a37a4d61f00cad5", size = 1917015, upload-time = "2025-12-08T01:53:27.987Z" }, + { url = "https://files.pythonhosted.org/packages/41/37/68accadf977672beb8e2c64e080f568c74159c1aaa6414b4cd2aef2d7906/black-25.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2355bbb6c3b76062870942d8cc450d4f8ac71f9c93c40122762c8784df49543f", size = 1741830, upload-time = "2025-12-08T01:54:36.861Z" }, + { url = "https://files.pythonhosted.org/packages/ac/76/03608a9d8f0faad47a3af3a3c8c53af3367f6c0dd2d23a84710456c7ac56/black-25.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9678bd991cc793e81d19aeeae57966ee02909877cb65838ccffef24c3ebac08f", size = 1791450, upload-time = "2025-12-08T01:44:52.581Z" }, + { url = "https://files.pythonhosted.org/packages/06/99/b2a4bd7dfaea7964974f947e1c76d6886d65fe5d24f687df2d85406b2609/black-25.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:97596189949a8aad13ad12fcbb4ae89330039b96ad6742e6f6b45e75ad5cfd83", size = 1452042, upload-time = "2025-12-08T01:46:13.188Z" }, + { url = "https://files.pythonhosted.org/packages/b2/7c/d9825de75ae5dd7795d007681b752275ea85a1c5d83269b4b9c754c2aaab/black-25.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:778285d9ea197f34704e3791ea9404cd6d07595745907dd2ce3da7a13627b29b", size = 1267446, upload-time = "2025-12-08T01:46:14.497Z" }, + { url = "https://files.pythonhosted.org/packages/68/11/21331aed19145a952ad28fca2756a1433ee9308079bd03bd898e903a2e53/black-25.12.0-py3-none-any.whl", hash = "sha256:48ceb36c16dbc84062740049eef990bb2ce07598272e673c17d1a7720c71c828", size = 206191, upload-time = "2025-12-08T01:40:50.963Z" }, ] [[package]] @@ -420,6 +445,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/0f/a2f53f5e7be49bfa98dcb4e552382a6dc8c74ea74e755723654b85062316/databind.json-4.5.2-py3-none-any.whl", hash = "sha256:a803bf440634685984361cb2a5a975887e487c854ed48d81ff7aaf3a1ed1e94c", size = 1473, upload-time = "2024-05-31T15:29:05.857Z" }, ] +[[package]] +name = "datamodel-code-generator" +version = "0.42.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argcomplete" }, + { name = "black" }, + { name = "genson" }, + { name = "inflect" }, + { name = "isort" }, + { name = "jinja2" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "tomli", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/88/e0/372aed4838be433829b0309868f1cf8776475fc8f8f7f47d784ecb394ea7/datamodel_code_generator-0.42.2.tar.gz", hash = "sha256:f7ac71eab3aa4bb1da2ebe0aabd4b7fe8abd2a66dd03cc492d57807e90a3a54e", size = 511348, upload-time = "2025-12-08T21:04:54.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/bd/fde595e1c48e100673fc6e1075b0a0e038b54f1c8f30770133d4e9f473aa/datamodel_code_generator-0.42.2-py3-none-any.whl", hash = "sha256:54430d6be3ea54ce5e70203d42e42881ef8156b6da395bca7355db5587653d91", size = 172014, upload-time = "2025-12-08T21:04:53.472Z" }, +] + +[package.optional-dependencies] +http = [ + { name = "httpx" }, +] +ruff = [ + { name = "ruff" }, +] + [[package]] name = "deprecated" version = "1.3.1" @@ -441,6 +495,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + [[package]] name = "docspec" version = "2.2.1" @@ -487,6 +550,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f8/1a/25272fafd13c92a2e3b8e351127410b9ea5557324bfea3552388d65797fc/dycw_pytest_only-2.1.1-py3-none-any.whl", hash = "sha256:ea8fe48878dd95ad0ca804e549225cf3b7a1928eb188c22a284c1d17b48a7b89", size = 2413, upload-time = "2025-06-03T01:04:46.585Z" }, ] +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -517,6 +593,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" }, ] +[[package]] +name = "genson" +version = "1.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c5/cf/2303c8ad276dcf5ee2ad6cf69c4338fd86ef0f471a5207b069adf7a393cf/genson-1.3.0.tar.gz", hash = "sha256:e02db9ac2e3fd29e65b5286f7135762e2cd8a986537c075b06fc5f1517308e37", size = 34919, upload-time = "2024-05-15T22:08:49.123Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/e226de133afd8bb267ec27eead9ae3d784b95b39a287ed404caab39a5f50/genson-1.3.0-py3-none-any.whl", hash = "sha256:468feccd00274cc7e4c09e84b08704270ba8d95232aa280f65b986139cec67f7", size = 21470, upload-time = "2024-05-15T22:08:47.056Z" }, +] + [[package]] name = "griffe" version = "1.15.0" @@ -529,6 +614,43 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + [[package]] name = "identify" version = "2.6.16" @@ -602,6 +724,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/ff/ba31bb3e0fa715251f3c9f344644a77a0a2294b0a8d3409ff53920c59fba/impit-0.9.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3b5383d7d0c3ea3fedf53416646da1570e1c0377037a8b294ca23cea14fe1c86", size = 6476703, upload-time = "2025-11-26T16:06:44.232Z" }, ] +[[package]] +name = "inflect" +version = "7.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, + { name = "typeguard" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/eb/427ed2b20a38a4ee29f24dbe4ae2dafab198674fe9a85e3d6adf9e5f5f41/inflect-7.5.0-py3-none-any.whl", hash = "sha256:2aea70e5e70c35d8350b8097396ec155ffd68def678c7ff97f51aa69c1d92344", size = 35197, upload-time = "2024-12-28T17:11:15.931Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -611,6 +746,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] +[[package]] +name = "isort" +version = "7.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/53/4f3c058e3bace40282876f9b553343376ee687f3c35a525dc79dbd450f88/isort-7.0.0.tar.gz", hash = "sha256:5513527951aadb3ac4292a41a16cbc50dd1642432f5e8c20057d414bdafb4187", size = 805049, upload-time = "2025-10-11T13:30:59.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/ed/e3705d6d02b4f7aea715a353c8ce193efd0b5db13e204df895d38734c244/isort-7.0.0-py3-none-any.whl", hash = "sha256:1bcabac8bc3c36c7fb7b98a76c8abb18e0f841a3ba81decac7691008592499c1", size = 94672, upload-time = "2025-10-11T13:30:57.665Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -841,6 +985,144 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/90/32c9941e728d564b411d574d8ee0cf09b12ec978cb22b294995bae5549a5/pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146", size = 2107298, upload-time = "2025-11-04T13:39:04.116Z" }, + { url = "https://files.pythonhosted.org/packages/fb/a8/61c96a77fe28993d9a6fb0f4127e05430a267b235a124545d79fea46dd65/pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2", size = 1901475, upload-time = "2025-11-04T13:39:06.055Z" }, + { url = "https://files.pythonhosted.org/packages/5d/b6/338abf60225acc18cdc08b4faef592d0310923d19a87fba1faf05af5346e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5921a4d3ca3aee735d9fd163808f5e8dd6c6972101e4adbda9a4667908849b97", size = 1918815, upload-time = "2025-11-04T13:39:10.41Z" }, + { url = "https://files.pythonhosted.org/packages/d1/1c/2ed0433e682983d8e8cba9c8d8ef274d4791ec6a6f24c58935b90e780e0a/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e25c479382d26a2a41b7ebea1043564a937db462816ea07afa8a44c0866d52f9", size = 2065567, upload-time = "2025-11-04T13:39:12.244Z" }, + { url = "https://files.pythonhosted.org/packages/b3/24/cf84974ee7d6eae06b9e63289b7b8f6549d416b5c199ca2d7ce13bbcf619/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f547144f2966e1e16ae626d8ce72b4cfa0caedc7fa28052001c94fb2fcaa1c52", size = 2230442, upload-time = "2025-11-04T13:39:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/fd/21/4e287865504b3edc0136c89c9c09431be326168b1eb7841911cbc877a995/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6f52298fbd394f9ed112d56f3d11aabd0d5bd27beb3084cc3d8ad069483b8941", size = 2350956, upload-time = "2025-11-04T13:39:15.889Z" }, + { url = "https://files.pythonhosted.org/packages/a8/76/7727ef2ffa4b62fcab916686a68a0426b9b790139720e1934e8ba797e238/pydantic_core-2.41.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:100baa204bb412b74fe285fb0f3a385256dad1d1879f0a5cb1499ed2e83d132a", size = 2068253, upload-time = "2025-11-04T13:39:17.403Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/a4abfc79604bcb4c748e18975c44f94f756f08fb04218d5cb87eb0d3a63e/pydantic_core-2.41.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:05a2c8852530ad2812cb7914dc61a1125dc4e06252ee98e5638a12da6cc6fb6c", size = 2177050, upload-time = "2025-11-04T13:39:19.351Z" }, + { url = "https://files.pythonhosted.org/packages/67/b1/de2e9a9a79b480f9cb0b6e8b6ba4c50b18d4e89852426364c66aa82bb7b3/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:29452c56df2ed968d18d7e21f4ab0ac55e71dc59524872f6fc57dcf4a3249ed2", size = 2147178, upload-time = "2025-11-04T13:39:21Z" }, + { url = "https://files.pythonhosted.org/packages/16/c1/dfb33f837a47b20417500efaa0378adc6635b3c79e8369ff7a03c494b4ac/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:d5160812ea7a8a2ffbe233d8da666880cad0cbaf5d4de74ae15c313213d62556", size = 2341833, upload-time = "2025-11-04T13:39:22.606Z" }, + { url = "https://files.pythonhosted.org/packages/47/36/00f398642a0f4b815a9a558c4f1dca1b4020a7d49562807d7bc9ff279a6c/pydantic_core-2.41.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:df3959765b553b9440adfd3c795617c352154e497a4eaf3752555cfb5da8fc49", size = 2321156, upload-time = "2025-11-04T13:39:25.843Z" }, + { url = "https://files.pythonhosted.org/packages/7e/70/cad3acd89fde2010807354d978725ae111ddf6d0ea46d1ea1775b5c1bd0c/pydantic_core-2.41.5-cp310-cp310-win32.whl", hash = "sha256:1f8d33a7f4d5a7889e60dc39856d76d09333d8a6ed0f5f1190635cbec70ec4ba", size = 1989378, upload-time = "2025-11-04T13:39:27.92Z" }, + { url = "https://files.pythonhosted.org/packages/76/92/d338652464c6c367e5608e4488201702cd1cbb0f33f7b6a85a60fe5f3720/pydantic_core-2.41.5-cp310-cp310-win_amd64.whl", hash = "sha256:62de39db01b8d593e45871af2af9e497295db8d73b085f6bfd0b18c83c70a8f9", size = 2013622, upload-time = "2025-11-04T13:39:29.848Z" }, + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/e6/b0/1a2aa41e3b5a4ba11420aba2d091b2d17959c8d1519ece3627c371951e73/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b5819cd790dbf0c5eb9f82c73c16b39a65dd6dd4d1439dcdea7816ec9adddab8", size = 2103351, upload-time = "2025-11-04T13:43:02.058Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ee/31b1f0020baaf6d091c87900ae05c6aeae101fa4e188e1613c80e4f1ea31/pydantic_core-2.41.5-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:5a4e67afbc95fa5c34cf27d9089bca7fcab4e51e57278d710320a70b956d1b9a", size = 1925363, upload-time = "2025-11-04T13:43:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/e1/89/ab8e86208467e467a80deaca4e434adac37b10a9d134cd2f99b28a01e483/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ece5c59f0ce7d001e017643d8d24da587ea1f74f6993467d85ae8a5ef9d4f42b", size = 2135615, upload-time = "2025-11-04T13:43:08.116Z" }, + { url = "https://files.pythonhosted.org/packages/99/0a/99a53d06dd0348b2008f2f30884b34719c323f16c3be4e6cc1203b74a91d/pydantic_core-2.41.5-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:16f80f7abe3351f8ea6858914ddc8c77e02578544a0ebc15b4c2e1a0e813b0b2", size = 2175369, upload-time = "2025-11-04T13:43:12.49Z" }, + { url = "https://files.pythonhosted.org/packages/6d/94/30ca3b73c6d485b9bb0bc66e611cff4a7138ff9736b7e66bcf0852151636/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:33cb885e759a705b426baada1fe68cbb0a2e68e34c5d0d0289a364cf01709093", size = 2144218, upload-time = "2025-11-04T13:43:15.431Z" }, + { url = "https://files.pythonhosted.org/packages/87/57/31b4f8e12680b739a91f472b5671294236b82586889ef764b5fbc6669238/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:c8d8b4eb992936023be7dee581270af5c6e0697a8559895f527f5b7105ecd36a", size = 2329951, upload-time = "2025-11-04T13:43:18.062Z" }, + { url = "https://files.pythonhosted.org/packages/7d/73/3c2c8edef77b8f7310e6fb012dbc4b8551386ed575b9eb6fb2506e28a7eb/pydantic_core-2.41.5-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:242a206cd0318f95cd21bdacff3fcc3aab23e79bba5cac3db5a841c9ef9c6963", size = 2318428, upload-time = "2025-11-04T13:43:20.679Z" }, + { url = "https://files.pythonhosted.org/packages/2f/02/8559b1f26ee0d502c74f9cca5c0d2fd97e967e083e006bbbb4e97f3a043a/pydantic_core-2.41.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:d3a978c4f57a597908b7e697229d996d77a6d3c94901e9edee593adada95ce1a", size = 2147009, upload-time = "2025-11-04T13:43:23.286Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + [[package]] name = "pydoc-markdown" version = "4.8.2" @@ -960,41 +1242,11 @@ wheels = [ [[package]] name = "pytokens" -version = "0.4.0" +version = "0.3.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e5/16/4b9cfd90d55e66ffdb277d7ebe3bc25250c2311336ec3fc73b2673c794d5/pytokens-0.4.0.tar.gz", hash = "sha256:6b0b03e6ea7c9f9d47c5c61164b69ad30f4f0d70a5d9fe7eac4d19f24f77af2d", size = 15039, upload-time = "2026-01-19T07:59:50.623Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/c5/c20818fef16c4ab5f9fd7bad699268ba21bf24f655711df4e33bb7a9ab47/pytokens-0.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:af0c3166aea367a9e755a283171befb92dd3043858b94ae9b3b7efbe9def26a3", size = 160682, upload-time = "2026-01-19T07:58:51.583Z" }, - { url = "https://files.pythonhosted.org/packages/46/c4/ad03e4abe05c6af57c4d7f8f031fafe80f0074796d09ab5a73bf2fac895f/pytokens-0.4.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae524ed14ca459932cbf51d74325bea643701ba8a8b0cc2d10f7cd4b3e2b63", size = 245748, upload-time = "2026-01-19T07:58:53.944Z" }, - { url = "https://files.pythonhosted.org/packages/6b/b9/4a7ee0a692603b16d8fdfbc5c44e0f6910d45eec6b2c2188daa4670f179d/pytokens-0.4.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e95cb158c44d642ed62f555bf8136bbe780dbd64d2fb0b9169e11ffb944664c3", size = 258671, upload-time = "2026-01-19T07:58:55.667Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a3/02bb29dc4985fb8d759d9c96f189c3a828e74f0879fdb843e9fb7a1db637/pytokens-0.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:df58d44630eaf25f587540e94bdf1fc50b4e6d5f212c786de0fb024bfcb8753a", size = 261749, upload-time = "2026-01-19T07:58:57.442Z" }, - { url = "https://files.pythonhosted.org/packages/10/f2/9a8bdcc5444d85d4dba4aa1b530d81af3edc4a9ab76bf1d53ea8bfe8479d/pytokens-0.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55efcc36f9a2e0e930cfba0ce7f83445306b02f8326745585ed5551864eba73a", size = 102805, upload-time = "2026-01-19T07:58:59.068Z" }, - { url = "https://files.pythonhosted.org/packages/b4/05/3196399a353dd4cd99138a88f662810979ee2f1a1cdb0b417cb2f4507836/pytokens-0.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:92eb3ef88f27c22dc9dbab966ace4d61f6826e02ba04dac8e2d65ea31df56c8e", size = 160075, upload-time = "2026-01-19T07:59:00.316Z" }, - { url = "https://files.pythonhosted.org/packages/28/1d/c8fc4ed0a1c4f660391b201cda00b1d5bbcc00e2998e8bcd48b15eefd708/pytokens-0.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4b77858a680635ee9904306f54b0ee4781effb89e211ba0a773d76539537165", size = 247318, upload-time = "2026-01-19T07:59:01.636Z" }, - { url = "https://files.pythonhosted.org/packages/8e/0e/53e55ba01f3e858d229cd84b02481542f42ba59050483a78bf2447ee1af7/pytokens-0.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25cacc20c2ad90acb56f3739d87905473c54ca1fa5967ffcd675463fe965865e", size = 259752, upload-time = "2026-01-19T07:59:04.229Z" }, - { url = "https://files.pythonhosted.org/packages/dc/56/2d930d7f899e3f21868ca6e8ec739ac31e8fc532f66e09cbe45d3df0a84f/pytokens-0.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:628fab535ebc9079e4db35cd63cb401901c7ce8720a9834f9ad44b9eb4e0f1d4", size = 262842, upload-time = "2026-01-19T07:59:06.14Z" }, - { url = "https://files.pythonhosted.org/packages/42/dd/4e7e6920d23deffaf66e6f40d45f7610dcbc132ca5d90ab4faccef22f624/pytokens-0.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:4d0f568d7e82b7e96be56d03b5081de40e43c904eb6492bf09aaca47cd55f35b", size = 102620, upload-time = "2026-01-19T07:59:07.839Z" }, - { url = "https://files.pythonhosted.org/packages/3d/65/65460ebbfefd0bc1b160457904370d44f269e6e4582e0a9b6cba7c267b04/pytokens-0.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd8da894e5a29ba6b6da8be06a4f7589d7220c099b5e363cb0643234b9b38c2a", size = 159864, upload-time = "2026-01-19T07:59:08.908Z" }, - { url = "https://files.pythonhosted.org/packages/25/70/a46669ec55876c392036b4da9808b5c3b1c5870bbca3d4cc923bf68bdbc1/pytokens-0.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:237ba7cfb677dbd3b01b09860810aceb448871150566b93cd24501d5734a04b1", size = 254448, upload-time = "2026-01-19T07:59:10.594Z" }, - { url = "https://files.pythonhosted.org/packages/62/0b/c486fc61299c2fc3b7f88ee4e115d4c8b6ffd1a7f88dc94b398b5b1bc4b8/pytokens-0.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01d1a61e36812e4e971cfe2c0e4c1f2d66d8311031dac8bf168af8a249fa04dd", size = 268863, upload-time = "2026-01-19T07:59:12.31Z" }, - { url = "https://files.pythonhosted.org/packages/79/92/b036af846707d25feaff7cafbd5280f1bd6a1034c16bb06a7c910209c1ab/pytokens-0.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e47e2ef3ec6ee86909e520d79f965f9b23389fda47460303cf715d510a6fe544", size = 267181, upload-time = "2026-01-19T07:59:13.856Z" }, - { url = "https://files.pythonhosted.org/packages/0d/c0/6d011fc00fefa74ce34816c84a923d2dd7c46b8dbc6ee52d13419786834c/pytokens-0.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:3d36954aba4557fd5a418a03cf595ecbb1cdcce119f91a49b19ef09d691a22ae", size = 102814, upload-time = "2026-01-19T07:59:15.288Z" }, - { url = "https://files.pythonhosted.org/packages/98/63/627b7e71d557383da5a97f473ad50f8d9c2c1f55c7d3c2531a120c796f6e/pytokens-0.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:73eff3bdd8ad08da679867992782568db0529b887bed4c85694f84cdf35eafc6", size = 159744, upload-time = "2026-01-19T07:59:16.88Z" }, - { url = "https://files.pythonhosted.org/packages/28/d7/16f434c37ec3824eba6bcb6e798e5381a8dc83af7a1eda0f95c16fe3ade5/pytokens-0.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d97cc1f91b1a8e8ebccf31c367f28225699bea26592df27141deade771ed0afb", size = 253207, upload-time = "2026-01-19T07:59:18.069Z" }, - { url = "https://files.pythonhosted.org/packages/ab/96/04102856b9527701ae57d74a6393d1aca5bad18a1b1ca48ccffb3c93b392/pytokens-0.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c8952c537cb73a1a74369501a83b7f9d208c3cf92c41dd88a17814e68d48ce", size = 267452, upload-time = "2026-01-19T07:59:19.328Z" }, - { url = "https://files.pythonhosted.org/packages/0e/ef/0936eb472b89ab2d2c2c24bb81c50417e803fa89c731930d9fb01176fe9f/pytokens-0.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5dbf56f3c748aed9310b310d5b8b14e2c96d3ad682ad5a943f381bdbbdddf753", size = 265965, upload-time = "2026-01-19T07:59:20.613Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f5/64f3d6f7df4a9e92ebda35ee85061f6260e16eac82df9396020eebbca775/pytokens-0.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:e131804513597f2dff2b18f9911d9b6276e21ef3699abeffc1c087c65a3d975e", size = 102813, upload-time = "2026-01-19T07:59:22.012Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f1/d07e6209f18ef378fc2ae9dee8d1dfe91fd2447c2e2dbfa32867b6dd30cf/pytokens-0.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0d7374c917197106d3c4761374718bc55ea2e9ac0fb94171588ef5840ee1f016", size = 159968, upload-time = "2026-01-19T07:59:23.07Z" }, - { url = "https://files.pythonhosted.org/packages/0a/73/0eb111400abd382a04f253b269819db9fcc748aa40748441cebdcb6d068f/pytokens-0.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0cd3fa1caf9e47a72ee134a29ca6b5bea84712724bba165d6628baa190c6ea5b", size = 253373, upload-time = "2026-01-19T07:59:24.381Z" }, - { url = "https://files.pythonhosted.org/packages/bd/8d/9e4e2fdb5bcaba679e54afcc304e9f13f488eb4d626e6b613f9553e03dbd/pytokens-0.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c6986576b7b07fe9791854caa5347923005a80b079d45b63b0be70d50cce5f1", size = 267024, upload-time = "2026-01-19T07:59:25.74Z" }, - { url = "https://files.pythonhosted.org/packages/cb/b7/e0a370321af2deb772cff14ff337e1140d1eac2c29a8876bfee995f486f0/pytokens-0.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9940f7c2e2f54fb1cb5fe17d0803c54da7a2bf62222704eb4217433664a186a7", size = 270912, upload-time = "2026-01-19T07:59:27.072Z" }, - { url = "https://files.pythonhosted.org/packages/7c/54/4348f916c440d4c3e68b53b4ed0e66b292d119e799fa07afa159566dcc86/pytokens-0.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:54691cf8f299e7efabcc25adb4ce715d3cef1491e1c930eaf555182f898ef66a", size = 103836, upload-time = "2026-01-19T07:59:28.112Z" }, - { url = "https://files.pythonhosted.org/packages/e8/f8/a693c0cfa9c783a2a8c4500b7b2a8bab420f8ca4f2d496153226bf1c12e3/pytokens-0.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:94ff5db97a0d3cd7248a5b07ba2167bd3edc1db92f76c6db00137bbaf068ddf8", size = 167643, upload-time = "2026-01-19T07:59:29.292Z" }, - { url = "https://files.pythonhosted.org/packages/c0/dd/a64eb1e9f3ec277b69b33ef1b40ffbcc8f0a3bafcde120997efc7bdefebf/pytokens-0.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0dd6261cd9cc95fae1227b1b6ebee023a5fd4a4b6330b071c73a516f5f59b63", size = 289553, upload-time = "2026-01-19T07:59:30.537Z" }, - { url = "https://files.pythonhosted.org/packages/df/22/06c1079d93dbc3bca5d013e1795f3d8b9ed6c87290acd6913c1c526a6bb2/pytokens-0.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0cdca8159df407dbd669145af4171a0d967006e0be25f3b520896bc7068f02c4", size = 302490, upload-time = "2026-01-19T07:59:32.352Z" }, - { url = "https://files.pythonhosted.org/packages/8d/de/a6f5e43115b4fbf4b93aa87d6c83c79932cdb084f9711daae04549e1e4ad/pytokens-0.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4b5770abeb2a24347380a1164a558f0ebe06e98aedbd54c45f7929527a5fb26e", size = 305652, upload-time = "2026-01-19T07:59:33.685Z" }, - { url = "https://files.pythonhosted.org/packages/ab/3d/c136e057cb622e36e0c3ff7a8aaa19ff9720050c4078235691da885fe6ee/pytokens-0.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:74500d72c561dad14c037a9e86a657afd63e277dd5a3bb7570932ab7a3b12551", size = 115472, upload-time = "2026-01-19T07:59:34.734Z" }, - { url = "https://files.pythonhosted.org/packages/7c/3c/6941a82f4f130af6e1c68c076b6789069ef10c04559bd4733650f902fd3b/pytokens-0.4.0-py3-none-any.whl", hash = "sha256:0508d11b4de157ee12063901603be87fb0253e8f4cb9305eb168b1202ab92068", size = 13224, upload-time = "2026-01-19T07:59:49.822Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/4e/8d/a762be14dae1c3bf280202ba3172020b2b0b4c537f94427435f19c413b72/pytokens-0.3.0.tar.gz", hash = "sha256:2f932b14ed08de5fcf0b391ace2642f858f1394c0857202959000b68ed7a458a", size = 17644, upload-time = "2025-11-05T13:36:35.34Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/25/d9db8be44e205a124f6c98bc0324b2bb149b7431c53877fc6d1038dddaf5/pytokens-0.3.0-py3-none-any.whl", hash = "sha256:95b2b5eaf832e469d141a378872480ede3f251a5a5041b8ec6e581d3ac71bbf3", size = 12195, upload-time = "2025-11-05T13:36:33.183Z" }, ] [[package]] @@ -1234,6 +1486,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d4/84/021bbeb7edb990dd6875cb6ab08d32faaa49fec63453d863730260a01f9e/typeapi-2.3.0-py3-none-any.whl", hash = "sha256:576b7dcb94412e91c5cae107a393674f8f99c10a24beb8be2302e3fed21d5cc2", size = 26858, upload-time = "2025-10-23T13:44:09.833Z" }, ] +[[package]] +name = "typeguard" +version = "4.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/68/71c1a15b5f65f40e91b65da23b8224dad41349894535a97f63a52e462196/typeguard-4.4.4.tar.gz", hash = "sha256:3a7fd2dffb705d4d0efaed4306a704c89b9dee850b688f060a8b1615a79e5f74", size = 75203, upload-time = "2025-06-18T09:56:07.624Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/a9/e3aee762739c1d7528da1c3e06d518503f8b6c439c35549b53735ba52ead/typeguard-4.4.4-py3-none-any.whl", hash = "sha256:b5f562281b6bfa1f5492470464730ef001646128b180769880468bd84b68b09e", size = 34874, upload-time = "2025-06-18T09:56:05.999Z" }, +] + [[package]] name = "types-colorama" version = "0.4.15.20250801" @@ -1252,6 +1516,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "urllib3" version = "2.6.3" diff --git a/website/src/pages/home_page_example.py b/website/src/pages/home_page_example.py index 13236b83..6b8a3fc3 100644 --- a/website/src/pages/home_page_example.py +++ b/website/src/pages/home_page_example.py @@ -13,6 +13,6 @@ async def main() -> None: return # Fetch results from the Actor run's default dataset. - dataset_client = apify_client.dataset(call_result['defaultDatasetId']) + dataset_client = apify_client.dataset(call_result.default_dataset_id) list_items_result = await dataset_client.list_items() print(f'Dataset: {list_items_result}')