diff --git a/src/apify_client/_models.py b/src/apify_client/_models.py index b4317a73..49c669a0 100644 --- a/src/apify_client/_models.py +++ b/src/apify_client/_models.py @@ -883,6 +883,13 @@ class CreateTaskRequest(BaseModel): input: TaskInput | None = None title: str | None = None actor_standby: ActorStandby | None = None + public_config: TaskPublicConfig | None = None + """ + Public-facing display configuration of the task's public landing page. The task is not + published by setting it — set `isPublic` via the [Update task](https://docs.apify.com/api/v2/actor-task-put) + endpoint for that. Setting `publicConfig` requires write permission to the task's Actor. + + """ @docs_group('Models') @@ -1330,6 +1337,10 @@ class EnvVarRequest(EnvVar): populate_by_name=True, alias_generator=to_camel, ) + value: Annotated[str, Field(examples=['my-value'])] + """ + The value of the environment variable. If `isSecret` is `true`, this value isn't returned by the API. + """ @docs_group('Models') @@ -3492,6 +3503,14 @@ class Task(BaseModel): title: str | None = None actor_standby: ActorStandby | None = None standby_url: AnyUrl | None = None + is_public: Annotated[bool | None, Field(examples=[False])] = None + """ + Whether the task is published on its public landing page. Derived from + `publicConfig.publishedAt`. Set it via the [Update task](https://docs.apify.com/api/v2/actor-task-put) + endpoint to publish or unpublish the task. + + """ + public_config: TaskPublicConfig | None = None @docs_group('Models') @@ -3523,6 +3542,52 @@ class TaskOptions(BaseModel): restart_on_error: Annotated[bool | None, Field(examples=[False])] = None +@docs_group('Models') +class TaskPublicConfig(BaseModel): + """Public-facing configuration of a published task, used by the task's public landing page. + The task's publication state is determined by `publishedAt` - a task is published when + `publishedAt` is set and unpublished when it is `null`. + + """ + + model_config = ConfigDict( + extra='allow', + populate_by_name=True, + alias_generator=to_camel, + ) + published_at: Annotated[AwareDatetime | None, Field(examples=['2025-06-16T09:20:45.777Z'])] = None + """ + Time when the task was published, or `null` if the task is not published. + This field is server-controlled - to publish or unpublish a task, set `isPublic` + via the [Update task](https://docs.apify.com/api/v2/actor-task-put) endpoint. + + """ + seo_title: Annotated[str | None, Field(examples=['Scrape data from a website'])] = None + """ + SEO title of the public task page. Defaults to the task title when not set. + """ + seo_description: str | None = None + """ + SEO description of the public task page. Defaults to the task description when not set. + """ + categorization: str | None = None + """ + Use-case category of the public task. + """ + input_schema_fields: list[str] | None = None + """ + Names of the task input fields displayed on the public task page. + """ + dataset_name: str | None = None + """ + Name of the dataset from the Actor's dataset schema whose results are displayed. + """ + dataset_view: str | None = None + """ + Key of the dataset view from the Actor's dataset schema used to display results. + """ + + @docs_group('Models') class TaskResponse(BaseModel): """Response containing Actor task data.""" @@ -3798,6 +3863,22 @@ class UpdateTaskRequest(BaseModel): input: TaskInput | None = None title: str | None = None actor_standby: ActorStandby | None = None + public_config: TaskPublicConfig | None = None + """ + Public-facing display configuration of the task's public landing page. The provided + fields are merged into the stored configuration and validated. The stored configuration + cannot be cleared this way. Set `isPublic` to change the publication state. + Updating `publicConfig` requires write permission to the task's Actor. + + """ + is_public: Annotated[bool | None, Field(examples=[True])] = None + """ + Set to `true` to publish the task on its public landing page, or `false` to unpublish it. + Sending the value the task already has does nothing. Publishing requires the task's + `publicConfig` to be filled in and write permission to the task's Actor; it fails if the + task is not ready to be published, leaving the rest of the update unapplied. + + """ @docs_group('Models') diff --git a/src/apify_client/_resource_clients/task.py b/src/apify_client/_resource_clients/task.py index ff79e72d..a25fac88 100644 --- a/src/apify_client/_resource_clients/task.py +++ b/src/apify_client/_resource_clients/task.py @@ -10,6 +10,7 @@ Task, TaskInput, TaskOptions, + TaskPublicConfig, TaskResponse, UpdateTaskRequest, ) @@ -83,6 +84,12 @@ def update( actor_standby_idle_timeout: timedelta | None = None, actor_standby_build: str | None = None, actor_standby_memory_mbytes: int | None = None, + is_public: bool | None = None, + public_config_seo_title: str | None = None, + public_config_seo_description: str | None = None, + public_config_input_schema_fields: list[str] | None = None, + public_config_dataset_name: str | None = None, + public_config_dataset_view: str | None = None, timeout: Timeout = 'short', ) -> Task: """Update the task with specified fields. @@ -111,6 +118,16 @@ def update( it will be shut down. actor_standby_build: The build tag or number to run when the Actor is in Standby mode. actor_standby_memory_mbytes: The memory in megabytes to use when the Actor is in Standby mode. + is_public: Set to `True` to publish the task on its public landing page, or `False` to unpublish it. + Passing the value the task already has does nothing. Publishing requires the public display + configuration to be filled in, and write access to the task's Actor. + public_config_seo_title: SEO title of the public task page. Defaults to the task title when not set. + public_config_seo_description: SEO description of the public task page. Defaults to the task description + when not set. + public_config_input_schema_fields: Names of the task input fields displayed on the public task page. + public_config_dataset_name: Name of the dataset from the Actor's dataset schema whose results are + displayed on the public task page. + public_config_dataset_view: View key from the Actor's dataset schema shown on the public task page. timeout: Timeout for the API HTTP request. Returns: @@ -123,6 +140,14 @@ def update( name=name, title=title, input=task_input, + is_public=is_public, + public_config=TaskPublicConfig( + seo_title=public_config_seo_title, + seo_description=public_config_seo_description, + input_schema_fields=public_config_input_schema_fields, + dataset_name=public_config_dataset_name, + dataset_view=public_config_dataset_view, + ), options=TaskOptions( build=build, max_items=max_items, @@ -141,6 +166,40 @@ def update( result = self._update(timeout=timeout, **task_fields.model_dump(by_alias=True, exclude_none=True)) return TaskResponse.model_validate(result).data + def publish(self, *, timeout: Timeout = 'short') -> Task: + """Publish the task on its public landing page. + + Convenience wrapper over `update` with `is_public` set to `True`. The task's Actor must be public and + the task must have its public display configuration set up. Requires write access to the task and to its + Actor. Publishing an already published task does nothing. + + https://docs.apify.com/api/v2#/reference/actor-tasks/task-object/update-task + + Args: + timeout: Timeout for the API HTTP request. + + Returns: + The published task. + """ + return self.update(is_public=True, timeout=timeout) + + def unpublish(self, *, timeout: Timeout = 'short') -> Task: + """Unpublish the task from its public landing page. + + Convenience wrapper over `update` with `is_public` set to `False`. The public display configuration is + preserved, so the task can be published again without re-entering it. Requires write access to the task + and to its Actor. Unpublishing a task that is not published does nothing. + + https://docs.apify.com/api/v2#/reference/actor-tasks/task-object/update-task + + Args: + timeout: Timeout for the API HTTP request. + + Returns: + The unpublished task. + """ + return self.update(is_public=False, timeout=timeout) + def delete(self, *, timeout: Timeout = 'short') -> None: """Delete the task. @@ -406,6 +465,12 @@ async def update( actor_standby_idle_timeout: timedelta | None = None, actor_standby_build: str | None = None, actor_standby_memory_mbytes: int | None = None, + is_public: bool | None = None, + public_config_seo_title: str | None = None, + public_config_seo_description: str | None = None, + public_config_input_schema_fields: list[str] | None = None, + public_config_dataset_name: str | None = None, + public_config_dataset_view: str | None = None, timeout: Timeout = 'short', ) -> Task: """Update the task with specified fields. @@ -434,6 +499,16 @@ async def update( it will be shut down. actor_standby_build: The build tag or number to run when the Actor is in Standby mode. actor_standby_memory_mbytes: The memory in megabytes to use when the Actor is in Standby mode. + is_public: Set to `True` to publish the task on its public landing page, or `False` to unpublish it. + Passing the value the task already has does nothing. Publishing requires the public display + configuration to be filled in, and write access to the task's Actor. + public_config_seo_title: SEO title of the public task page. Defaults to the task title when not set. + public_config_seo_description: SEO description of the public task page. Defaults to the task description + when not set. + public_config_input_schema_fields: Names of the task input fields displayed on the public task page. + public_config_dataset_name: Name of the dataset from the Actor's dataset schema whose results are + displayed on the public task page. + public_config_dataset_view: View key from the Actor's dataset schema shown on the public task page. timeout: Timeout for the API HTTP request. Returns: @@ -446,6 +521,14 @@ async def update( name=name, title=title, input=task_input, + is_public=is_public, + public_config=TaskPublicConfig( + seo_title=public_config_seo_title, + seo_description=public_config_seo_description, + input_schema_fields=public_config_input_schema_fields, + dataset_name=public_config_dataset_name, + dataset_view=public_config_dataset_view, + ), options=TaskOptions( build=build, max_items=max_items, @@ -464,6 +547,40 @@ async def update( result = await self._update(timeout=timeout, **task_fields.model_dump(by_alias=True, exclude_none=True)) return TaskResponse.model_validate(result).data + async def publish(self, *, timeout: Timeout = 'short') -> Task: + """Publish the task on its public landing page. + + Convenience wrapper over `update` with `is_public` set to `True`. The task's Actor must be public and + the task must have its public display configuration set up. Requires write access to the task and to its + Actor. Publishing an already published task does nothing. + + https://docs.apify.com/api/v2#/reference/actor-tasks/task-object/update-task + + Args: + timeout: Timeout for the API HTTP request. + + Returns: + The published task. + """ + return await self.update(is_public=True, timeout=timeout) + + async def unpublish(self, *, timeout: Timeout = 'short') -> Task: + """Unpublish the task from its public landing page. + + Convenience wrapper over `update` with `is_public` set to `False`. The public display configuration is + preserved, so the task can be published again without re-entering it. Requires write access to the task + and to its Actor. Unpublishing a task that is not published does nothing. + + https://docs.apify.com/api/v2#/reference/actor-tasks/task-object/update-task + + Args: + timeout: Timeout for the API HTTP request. + + Returns: + The unpublished task. + """ + return await self.update(is_public=False, timeout=timeout) + async def delete(self, *, timeout: Timeout = 'short') -> None: """Delete the task. diff --git a/src/apify_client/_resource_clients/task_collection.py b/src/apify_client/_resource_clients/task_collection.py index cabef51f..51d4e0fc 100644 --- a/src/apify_client/_resource_clients/task_collection.py +++ b/src/apify_client/_resource_clients/task_collection.py @@ -11,6 +11,7 @@ Task, TaskInput, TaskOptions, + TaskPublicConfig, TaskResponse, ) from apify_client._pagination import get_items_iterator, get_items_iterator_async @@ -116,10 +117,18 @@ def create( actor_standby_idle_timeout: timedelta | None = None, actor_standby_build: str | None = None, actor_standby_memory_mbytes: int | None = None, + public_config_seo_title: str | None = None, + public_config_seo_description: str | None = None, + public_config_input_schema_fields: list[str] | None = None, # ty: ignore[invalid-type-form] + public_config_dataset_name: str | None = None, + public_config_dataset_view: str | None = None, timeout: Timeout = 'medium', ) -> Task: """Create a new task. + The `public_config_*` arguments set the public display configuration of the task's landing page, which + requires write access to the task's Actor and the task itself. Use `TaskClient.publish` for publishing. + https://docs.apify.com/api/v2#/reference/actor-tasks/task-collection/create-task Args: @@ -145,6 +154,13 @@ def create( it will be shut down. actor_standby_build: The build tag or number to run when the Actor is in Standby mode. actor_standby_memory_mbytes: The memory in megabytes to use when the Actor is in Standby mode. + public_config_seo_title: SEO title of the public task page. Defaults to the task title when not set. + public_config_seo_description: SEO description of the public task page. Defaults to the task description + when not set. + public_config_input_schema_fields: Names of the task input fields displayed on the public task page. + public_config_dataset_name: Name of the dataset from the Actor's dataset schema whose results are + displayed on the public task page. + public_config_dataset_view: View key from the Actor's dataset schema shown on the public task page. timeout: Timeout for the API HTTP request. Returns: @@ -158,6 +174,13 @@ def create( name=name, title=title, input=task_input, + public_config=TaskPublicConfig( + seo_title=public_config_seo_title, + seo_description=public_config_seo_description, + input_schema_fields=public_config_input_schema_fields, + dataset_name=public_config_dataset_name, + dataset_view=public_config_dataset_view, + ), options=TaskOptions( build=build, max_items=max_items, @@ -267,10 +290,18 @@ async def create( actor_standby_idle_timeout: timedelta | None = None, actor_standby_build: str | None = None, actor_standby_memory_mbytes: int | None = None, + public_config_seo_title: str | None = None, + public_config_seo_description: str | None = None, + public_config_input_schema_fields: list[str] | None = None, # ty: ignore[invalid-type-form] + public_config_dataset_name: str | None = None, + public_config_dataset_view: str | None = None, timeout: Timeout = 'medium', ) -> Task: """Create a new task. + The `public_config_*` arguments set the public display configuration of the task's landing page, which + requires write access to the task's Actor and the task itself. Use `TaskClientAsync.publish` for publishing. + https://docs.apify.com/api/v2#/reference/actor-tasks/task-collection/create-task Args: @@ -296,6 +327,13 @@ async def create( it will be shut down. actor_standby_build: The build tag or number to run when the Actor is in Standby mode. actor_standby_memory_mbytes: The memory in megabytes to use when the Actor is in Standby mode. + public_config_seo_title: SEO title of the public task page. Defaults to the task title when not set. + public_config_seo_description: SEO description of the public task page. Defaults to the task description + when not set. + public_config_input_schema_fields: Names of the task input fields displayed on the public task page. + public_config_dataset_name: Name of the dataset from the Actor's dataset schema whose results are + displayed on the public task page. + public_config_dataset_view: View key from the Actor's dataset schema shown on the public task page. timeout: Timeout for the API HTTP request. Returns: @@ -309,6 +347,13 @@ async def create( name=name, title=title, input=task_input, + public_config=TaskPublicConfig( + seo_title=public_config_seo_title, + seo_description=public_config_seo_description, + input_schema_fields=public_config_input_schema_fields, + dataset_name=public_config_dataset_name, + dataset_view=public_config_dataset_view, + ), options=TaskOptions( build=build, max_items=max_items, diff --git a/src/apify_client/_typeddicts.py b/src/apify_client/_typeddicts.py index 46854805..adbad385 100644 --- a/src/apify_client/_typeddicts.py +++ b/src/apify_client/_typeddicts.py @@ -39,7 +39,7 @@ class RequestBaseDict(TypedDict): """ Indicates whether the request should not be retried if processing fails. """ - error_messages: NotRequired[list[str]] + error_messages: NotRequired[list[str] | None] """ Error messages recorded from failed processing attempts. """ @@ -81,7 +81,7 @@ class RequestBaseCamelDict(TypedDict): """ Indicates whether the request should not be retried if processing fails. """ - errorMessages: NotRequired[list[str]] + errorMessages: NotRequired[list[str] | None] """ Error messages recorded from failed processing attempts. """ diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 8ee3d0a8..467ef262 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -6,6 +6,8 @@ import pytest from pytest_httpserver import HTTPServer +from apify_client import ApifyClient, ApifyClientAsync + if TYPE_CHECKING: from collections.abc import Iterable @@ -28,3 +30,13 @@ def httpserver(make_httpserver: HTTPServer) -> Iterable[HTTPServer]: server = make_httpserver yield server server.clear() + + +@pytest.fixture +def sync_client(httpserver: HTTPServer) -> ApifyClient: + return ApifyClient(token='test', api_url=httpserver.url_for('/').removesuffix('/')) + + +@pytest.fixture +def async_client(httpserver: HTTPServer) -> ApifyClientAsync: + return ApifyClientAsync(token='test', api_url=httpserver.url_for('/').removesuffix('/')) diff --git a/tests/unit/test_client_errors.py b/tests/unit/test_client_errors.py index a0c1c0b9..9d81d2de 100644 --- a/tests/unit/test_client_errors.py +++ b/tests/unit/test_client_errors.py @@ -7,7 +7,6 @@ import pytest from werkzeug import Response -from apify_client import ApifyClient, ApifyClientAsync from apify_client.errors import ( ApifyApiError, ConflictError, @@ -26,6 +25,8 @@ from pytest_httpserver import HTTPServer from werkzeug import Request + from apify_client import ApifyClient, ApifyClientAsync + _TEST_PATH = '/errors' _EXPECTED_MESSAGE = 'some_message' _EXPECTED_TYPE = 'some_type' @@ -83,16 +84,6 @@ def streaming_handler(_request: Request) -> Response: ) -@pytest.fixture -def sync_client(httpserver: HTTPServer) -> ApifyClient: - return ApifyClient(token='test', api_url=httpserver.url_for('/').removesuffix('/')) - - -@pytest.fixture -def async_client(httpserver: HTTPServer) -> ApifyClientAsync: - return ApifyClientAsync(token='test', api_url=httpserver.url_for('/').removesuffix('/')) - - @pytest.fixture def test_endpoint(httpserver: HTTPServer) -> str: httpserver.expect_request(_TEST_PATH).respond_with_json( diff --git a/tests/unit/test_task_publication.py b/tests/unit/test_task_publication.py new file mode 100644 index 00000000..97dc7fa3 --- /dev/null +++ b/tests/unit/test_task_publication.py @@ -0,0 +1,278 @@ +from __future__ import annotations + +import json +from datetime import UTC, datetime +from typing import TYPE_CHECKING, Any + +from werkzeug import Request, Response + +if TYPE_CHECKING: + from collections.abc import Callable + + from pytest_httpserver import HTTPServer + + from apify_client import ApifyClient, ApifyClientAsync + +_MOCKED_TASK_ID = 'test_task_id' +_TASK_PATH = f'/v2/actor-tasks/{_MOCKED_TASK_ID}' +_TASKS_PATH = '/v2/actor-tasks' + +_PUBLISHED_AT = '2026-08-01T10:00:00.000Z' + +_TASK_RESPONSE = { + 'data': { + 'id': _MOCKED_TASK_ID, + 'userId': 'test_user_id', + 'actId': 'test_actor_id', + 'name': 'test-task', + 'createdAt': '2026-08-01T10:00:00.000Z', + 'modifiedAt': '2026-08-01T10:00:00.000Z', + # Publication state as the API returns it, so the deserialization half is exercised too. + 'isPublic': True, + 'publicConfig': { + 'publishedAt': _PUBLISHED_AT, + 'seoTitle': 'Scrape a website', + 'inputSchemaFields': ['query'], + 'datasetName': 'default', + 'datasetView': 'overview', + }, + } +} + + +def _capture(captured: list[Request]) -> Callable[[Request], Response]: + def handler(request: Request) -> Response: + captured.append(request) + return Response(json.dumps(_TASK_RESPONSE), status=200, mimetype='application/json') + + return handler + + +def _body_of(request: Request) -> dict[str, Any]: + return json.loads(request.get_data()) + + +def test_publish_sets_is_public_true(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`publish` sends `isPublic: true` and nothing else.""" + captured: list[Request] = [] + httpserver.expect_request(_TASK_PATH, method='PUT').respond_with_handler(_capture(captured)) + + sync_client.task(_MOCKED_TASK_ID).publish() + + assert len(captured) == 1 + assert _body_of(captured[0]) == {'isPublic': True} + + +def test_publish_returns_the_published_task(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`publish` returns the parsed task, including its publication state and display configuration.""" + httpserver.expect_request(_TASK_PATH, method='PUT').respond_with_json(_TASK_RESPONSE) + + task = sync_client.task(_MOCKED_TASK_ID).publish() + + assert task.is_public is True + assert task.public_config is not None + assert task.public_config.published_at == datetime(2026, 8, 1, 10, 0, tzinfo=UTC) + assert task.public_config.seo_title == 'Scrape a website' + assert task.public_config.input_schema_fields == ['query'] + assert task.public_config.dataset_view == 'overview' + + +def test_unpublish_sets_is_public_false(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """`unpublish` sends `isPublic: false` rather than omitting the field.""" + captured: list[Request] = [] + httpserver.expect_request(_TASK_PATH, method='PUT').respond_with_handler(_capture(captured)) + + sync_client.task(_MOCKED_TASK_ID).unpublish() + + assert len(captured) == 1 + # `False` must survive `exclude_none` - it is what unpublishes the task. + assert _body_of(captured[0]) == {'isPublic': False} + + +def test_update_sends_only_the_requested_fields(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """A plain update must send nothing but the field it was asked to change. + + Asserted as an exact body rather than as absences, because both extra keys would be damaging: a present + `publicConfig` - even an empty object - is treated by the API as an edit of the public landing page and + requires write access to the task's Actor, and a stray `isPublic: false` would silently unpublish the task. + """ + captured: list[Request] = [] + httpserver.expect_request(_TASK_PATH, method='PUT').respond_with_handler(_capture(captured)) + + sync_client.task(_MOCKED_TASK_ID).update(name='renamed') + + assert len(captured) == 1 + assert _body_of(captured[0]) == {'name': 'renamed'} + + +def test_update_sends_public_config_fields(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """The `public_config_*` arguments are nested under `publicConfig` with their API names.""" + captured: list[Request] = [] + httpserver.expect_request(_TASK_PATH, method='PUT').respond_with_handler(_capture(captured)) + + sync_client.task(_MOCKED_TASK_ID).update( + public_config_seo_title='Scrape a website', + public_config_input_schema_fields=['query'], + public_config_dataset_view='overview', + ) + + assert len(captured) == 1 + assert _body_of(captured[0])['publicConfig'] == { + 'seoTitle': 'Scrape a website', + 'inputSchemaFields': ['query'], + 'datasetView': 'overview', + } + + +def test_update_can_configure_and_publish_at_once(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """A single update can both fill in the display configuration and publish the task.""" + captured: list[Request] = [] + httpserver.expect_request(_TASK_PATH, method='PUT').respond_with_handler(_capture(captured)) + + sync_client.task(_MOCKED_TASK_ID).update(is_public=True, public_config_dataset_view='overview') + + assert len(captured) == 1 + body = _body_of(captured[0]) + assert body['isPublic'] is True + assert body['publicConfig'] == {'datasetView': 'overview'} + + +def test_create_sends_public_config_without_publishing(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """Create accepts the display configuration but never publishes the task.""" + captured: list[Request] = [] + httpserver.expect_request(_TASKS_PATH, method='POST').respond_with_handler(_capture(captured)) + + sync_client.tasks().create( + actor_id='test_actor_id', + name='test-task', + public_config_seo_title='Scrape a website', + ) + + assert len(captured) == 1 + body = _body_of(captured[0]) + assert body['publicConfig'] == {'seoTitle': 'Scrape a website'} + assert 'isPublic' not in body + + +def test_create_sends_only_the_requested_fields(httpserver: HTTPServer, sync_client: ApifyClient) -> None: + """A plain create must not send an empty `publicConfig`, which would demand Actor write access.""" + captured: list[Request] = [] + httpserver.expect_request(_TASKS_PATH, method='POST').respond_with_handler(_capture(captured)) + + sync_client.tasks().create(actor_id='test_actor_id', name='test-task') + + assert len(captured) == 1 + assert _body_of(captured[0]) == {'actId': 'test_actor_id', 'name': 'test-task'} + + +async def test_publish_sets_is_public_true_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """`publish` sends `isPublic: true` and nothing else.""" + captured: list[Request] = [] + httpserver.expect_request(_TASK_PATH, method='PUT').respond_with_handler(_capture(captured)) + + await async_client.task(_MOCKED_TASK_ID).publish() + + assert len(captured) == 1 + assert _body_of(captured[0]) == {'isPublic': True} + + +async def test_publish_returns_the_published_task_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """`publish` returns the parsed task, including its publication state and display configuration.""" + httpserver.expect_request(_TASK_PATH, method='PUT').respond_with_json(_TASK_RESPONSE) + + task = await async_client.task(_MOCKED_TASK_ID).publish() + + assert task.is_public is True + assert task.public_config is not None + assert task.public_config.published_at == datetime(2026, 8, 1, 10, 0, tzinfo=UTC) + assert task.public_config.dataset_view == 'overview' + + +async def test_unpublish_sets_is_public_false_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """`unpublish` sends `isPublic: false` rather than omitting the field.""" + captured: list[Request] = [] + httpserver.expect_request(_TASK_PATH, method='PUT').respond_with_handler(_capture(captured)) + + await async_client.task(_MOCKED_TASK_ID).unpublish() + + assert len(captured) == 1 + assert _body_of(captured[0]) == {'isPublic': False} + + +async def test_update_sends_only_the_requested_fields_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """A plain update must send nothing but the field it was asked to change.""" + captured: list[Request] = [] + httpserver.expect_request(_TASK_PATH, method='PUT').respond_with_handler(_capture(captured)) + + await async_client.task(_MOCKED_TASK_ID).update(name='renamed') + + assert len(captured) == 1 + assert _body_of(captured[0]) == {'name': 'renamed'} + + +async def test_update_sends_public_config_fields_async(httpserver: HTTPServer, async_client: ApifyClientAsync) -> None: + """The `public_config_*` arguments are nested under `publicConfig` with their API names.""" + captured: list[Request] = [] + httpserver.expect_request(_TASK_PATH, method='PUT').respond_with_handler(_capture(captured)) + + await async_client.task(_MOCKED_TASK_ID).update( + public_config_seo_title='Scrape a website', + public_config_input_schema_fields=['query'], + public_config_dataset_view='overview', + ) + + assert len(captured) == 1 + assert _body_of(captured[0])['publicConfig'] == { + 'seoTitle': 'Scrape a website', + 'inputSchemaFields': ['query'], + 'datasetView': 'overview', + } + + +async def test_update_can_configure_and_publish_at_once_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """A single update can both fill in the display configuration and publish the task.""" + captured: list[Request] = [] + httpserver.expect_request(_TASK_PATH, method='PUT').respond_with_handler(_capture(captured)) + + await async_client.task(_MOCKED_TASK_ID).update(is_public=True, public_config_dataset_view='overview') + + assert len(captured) == 1 + body = _body_of(captured[0]) + assert body['isPublic'] is True + assert body['publicConfig'] == {'datasetView': 'overview'} + + +async def test_create_sends_public_config_without_publishing_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """Create accepts the display configuration but never publishes the task.""" + captured: list[Request] = [] + httpserver.expect_request(_TASKS_PATH, method='POST').respond_with_handler(_capture(captured)) + + await async_client.tasks().create( + actor_id='test_actor_id', + name='test-task', + public_config_seo_title='Scrape a website', + ) + + assert len(captured) == 1 + body = _body_of(captured[0]) + assert body['publicConfig'] == {'seoTitle': 'Scrape a website'} + assert 'isPublic' not in body + + +async def test_create_sends_only_the_requested_fields_async( + httpserver: HTTPServer, async_client: ApifyClientAsync +) -> None: + """A plain create must not send an empty `publicConfig`, which would demand Actor write access.""" + captured: list[Request] = [] + httpserver.expect_request(_TASKS_PATH, method='POST').respond_with_handler(_capture(captured)) + + await async_client.tasks().create(actor_id='test_actor_id', name='test-task') + + assert len(captured) == 1 + assert _body_of(captured[0]) == {'actId': 'test_actor_id', 'name': 'test-task'}