From 27b3e03de3018a98983415606f03a2b968532c61 Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Tue, 20 Oct 2026 17:11:33 +0200 Subject: [PATCH 1/4] feat: read Actor input from the storage directory after the working directory --- docs/02_concepts/02_actor_input.mdx | 4 +- src/apify/_actor.py | 32 ++++++---- .../unit/actor/test_actor_key_value_store.py | 61 ++++++++++++++++--- 3 files changed, 74 insertions(+), 23 deletions(-) diff --git a/docs/02_concepts/02_actor_input.mdx b/docs/02_concepts/02_actor_input.mdx index 3c434c5a..330a1d1d 100644 --- a/docs/02_concepts/02_actor_input.mdx +++ b/docs/02_concepts/02_actor_input.mdx @@ -20,9 +20,9 @@ For example, if an Actor received a JSON input with two fields, `{ "firstNumber" {InputExample} -## Input file in the working directory +## Input file in the working or storage directory -When you run the Actor locally and its default key-value store holds no input record, `Actor.get_input` reads the input from an `INPUT.json` or a bare `INPUT` file in the current working directory. The file name follows the configured input key. The `.json` file is parsed as JSON. The bare file is parsed as JSON when it's valid JSON and returned as `bytes` otherwise. If both files exist, `Actor.get_input` raises an error. On the Apify platform, the working directory is never read. +When you run the Actor locally and its default key-value store holds no input record, `Actor.get_input` reads the input from an `INPUT.json` or a bare `INPUT` file in the current working directory, and then in the storage directory (`APIFY_LOCAL_STORAGE_DIR`, `./storage` by default). The working directory takes precedence. The file name follows the configured input key. The `.json` file is parsed as JSON. The bare file is parsed as JSON when it's valid JSON and returned as `bytes` otherwise. If both files exist in the same directory, `Actor.get_input` raises an error. On the Apify platform, neither directory is read. ## Validating input diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 6026d789..d6caae08 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -785,21 +785,28 @@ async def get_input(self) -> Any: plaintext before being returned. When running locally and the default key-value store holds no input record, the input is read from a - `` or `.json` file in the current working directory instead. The `.json` file is parsed - as JSON; the bare file is parsed as JSON when it is valid JSON, and returned as `bytes` otherwise. + `` or `.json` file in the current working directory, then in the storage directory + (`Configuration.storage_dir`), instead. The `.json` file is parsed as JSON; the bare file is parsed as JSON + when it is valid JSON, and returned as `bytes` otherwise. Returns: The Actor input, usually a `dict` of input fields, or `None` if the Actor has no input. Raises: - RuntimeError: If both `` and `.json` exist in the working directory. - ValueError: If the `.json` file in the working directory is not valid JSON. + RuntimeError: If both `` and `.json` exist in the working or storage directory. + ValueError: If the `.json` file in the working or storage directory is not valid JSON. """ input_key = self.configuration.input_key key_value_store = await self.open_key_value_store() input_value = await key_value_store.get_value(input_key) if input_value is None and not self.is_at_home() and not await key_value_store.record_exists(input_key): - input_value = await self._read_input_from_working_directory(input_key) + input_path = await self._find_input_file(input_key, Path.cwd(), 'working directory') + if input_path is None: + input_path = await self._find_input_file( + input_key, Path(self.configuration.storage_dir), 'storage directory' + ) + if input_path is not None: + input_value = await self._read_input_file(input_path, input_key) input_secrets_private_key = self.configuration.input_secrets_private_key_file input_secrets_key_passphrase = self.configuration.input_secrets_private_key_passphrase @@ -1850,19 +1857,20 @@ def _get_default_exit_process(self) -> bool: return True @staticmethod - async def _read_input_from_working_directory(input_key: str) -> Any: - """Read the Actor input from a `` or `.json` file in the current working directory.""" - candidates = [Path.cwd() / input_key, Path.cwd() / f'{input_key}.json'] + async def _find_input_file(input_key: str, directory: Path, label: str) -> Path | None: + """Find the `` or `.json` input file in the given directory, if there is one.""" + candidates = [directory / input_key, directory / f'{input_key}.json'] found = [path for path in candidates if await asyncio.to_thread(path.is_file)] if len(found) > 1: names = ', '.join(f'"{path.name}"' for path in found) - raise RuntimeError(f'Found multiple input files in the working directory: {names}. Keep only one of them.') + raise RuntimeError(f'Found multiple input files in the {label}: {names}. Keep only one of them.') - if not found: - return None + return found[0] if found else None - input_path = found[0] + @staticmethod + async def _read_input_file(input_path: Path, input_key: str) -> Any: + """Read the Actor input from an input file found by `_find_input_file`.""" content = await asyncio.to_thread(input_path.read_bytes) try: diff --git a/tests/unit/actor/test_actor_key_value_store.py b/tests/unit/actor/test_actor_key_value_store.py index c36ac983..4a5ad456 100644 --- a/tests/unit/actor/test_actor_key_value_store.py +++ b/tests/unit/actor/test_actor_key_value_store.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from pathlib import Path import pytest @@ -11,9 +11,6 @@ from apify._consts import ENCRYPTED_JSON_VALUE_PREFIX, ENCRYPTED_STRING_VALUE_PREFIX, ActorEnvVars, ApifyEnvVars from apify._crypto import public_encrypt -if TYPE_CHECKING: - from pathlib import Path - async def test_open_returns_same_references() -> None: async with Actor: @@ -268,16 +265,62 @@ async def test_get_input_raises_on_multiple_working_directory_files( (tmp_path / 'INPUT.json').write_text('{}') async with Actor as actor: - with pytest.raises(RuntimeError, match='multiple input files'): + with pytest.raises(RuntimeError, match='multiple input files in the working directory'): await actor.get_input() -async def test_get_input_skips_working_directory_on_platform(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """On the Apify platform, the working directory is never read.""" - monkeypatch.chdir(tmp_path) - (tmp_path / 'INPUT.json').write_text('{"from": "file"}') +async def test_get_input_falls_back_to_storage_directory(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Without an input record, `INPUT.json` in the storage directory is read after the working directory.""" + working_dir = tmp_path / 'working_dir' + working_dir.mkdir() + monkeypatch.chdir(working_dir) + (tmp_path / 'INPUT.json').write_text('{"from": "storage"}') + + async with Actor as actor: + assert await actor.get_input() == {'from': 'storage'} + + (working_dir / 'INPUT.json').write_text('{"from": "cwd"}') + assert await actor.get_input() == {'from': 'cwd'} + + +async def test_get_input_null_working_directory_file_skips_storage_directory( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A working-directory file holding JSON `null` counts as present, so the storage directory is not read.""" + working_dir = tmp_path / 'working_dir' + working_dir.mkdir() + monkeypatch.chdir(working_dir) + (working_dir / 'INPUT.json').write_text('null') + (tmp_path / 'INPUT.json').write_text('{"from": "storage"}') + + async with Actor as actor: + assert await actor.get_input() is None + + +async def test_get_input_raises_on_multiple_storage_directory_files( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Both `INPUT` and `INPUT.json` in the storage directory raise an error naming the storage directory.""" + working_dir = tmp_path / 'working_dir' + working_dir.mkdir() + monkeypatch.chdir(working_dir) + (tmp_path / 'INPUT').write_text('{}') + (tmp_path / 'INPUT.json').write_text('{}') + + async with Actor as actor: + with pytest.raises(RuntimeError, match='multiple input files in the storage directory'): + await actor.get_input() + + +async def test_get_input_skips_input_files_on_platform(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """On the Apify platform, neither the working directory nor the storage directory is read.""" + working_dir = tmp_path / 'working_dir' + working_dir.mkdir() + monkeypatch.chdir(working_dir) + (working_dir / 'INPUT.json').write_text('{"from": "cwd"}') async with Actor as actor: + (Path(actor.configuration.storage_dir) / 'INPUT.json').write_text('{"from": "storage"}') monkeypatch.setattr(actor, 'is_at_home', lambda: True) assert await actor.get_input() is None From e7962ee379153cd840c89a82d25f47b767f20984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Josef=20Proch=C3=A1zka?= Date: Wed, 21 Oct 2026 10:03:08 +0200 Subject: [PATCH 2/4] Apply batched suggestions from code review Co-authored-by: Vlada Dusek --- src/apify/_actor.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/apify/_actor.py b/src/apify/_actor.py index d6caae08..ed5e11b7 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -785,9 +785,9 @@ async def get_input(self) -> Any: plaintext before being returned. When running locally and the default key-value store holds no input record, the input is read from a - `` or `.json` file in the current working directory, then in the storage directory - (`Configuration.storage_dir`), instead. The `.json` file is parsed as JSON; the bare file is parsed as JSON - when it is valid JSON, and returned as `bytes` otherwise. + `` or `.json` file, looked up first in the current working directory and then in the + storage directory (`Configuration.storage_dir`). The `.json` file is parsed as JSON; the bare file is parsed as + JSON when it is valid JSON, and returned as `bytes` otherwise. Returns: The Actor input, usually a `dict` of input fields, or `None` if the Actor has no input. @@ -1870,7 +1870,7 @@ async def _find_input_file(input_key: str, directory: Path, label: str) -> Path @staticmethod async def _read_input_file(input_path: Path, input_key: str) -> Any: - """Read the Actor input from an input file found by `_find_input_file`.""" + """Read the Actor input from the given `` or `.json` input file.""" content = await asyncio.to_thread(input_path.read_bytes) try: From eab0d7f59b2bdd460940746b94a3e69a3a764ac9 Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Wed, 21 Oct 2026 12:11:08 +0200 Subject: [PATCH 3/4] fix: look for local input in the default key-value store directory instead of the storage root --- docs/02_concepts/02_actor_input.mdx | 4 +- src/apify/_actor.py | 17 ++-- .../unit/actor/test_actor_key_value_store.py | 84 ++++++++++++------- 3 files changed, 70 insertions(+), 35 deletions(-) diff --git a/docs/02_concepts/02_actor_input.mdx b/docs/02_concepts/02_actor_input.mdx index 330a1d1d..e8979d98 100644 --- a/docs/02_concepts/02_actor_input.mdx +++ b/docs/02_concepts/02_actor_input.mdx @@ -20,9 +20,9 @@ For example, if an Actor received a JSON input with two fields, `{ "firstNumber" {InputExample} -## Input file in the working or storage directory +## Input file in the working directory -When you run the Actor locally and its default key-value store holds no input record, `Actor.get_input` reads the input from an `INPUT.json` or a bare `INPUT` file in the current working directory, and then in the storage directory (`APIFY_LOCAL_STORAGE_DIR`, `./storage` by default). The working directory takes precedence. The file name follows the configured input key. The `.json` file is parsed as JSON. The bare file is parsed as JSON when it's valid JSON and returned as `bytes` otherwise. If both files exist in the same directory, `Actor.get_input` raises an error. On the Apify platform, neither directory is read. +When you run the Actor locally and its default key-value store holds no input record, `Actor.get_input` reads the input from an `INPUT.json` or a bare `INPUT` file in the current working directory, and then in the default key-value store directory (`/key_value_stores/`, `storage/key_value_stores/default` by default, where `apify run` writes it). The latter covers storage clients that don't read that directory, such as in-memory storage. The working directory takes precedence. The file name follows the configured input key. The `.json` file is parsed as JSON. The bare file is parsed as JSON when it's valid JSON and returned as `bytes` otherwise. If both files exist in the same directory, `Actor.get_input` raises an error. On the Apify platform, neither directory is read. ## Validating input diff --git a/src/apify/_actor.py b/src/apify/_actor.py index ed5e11b7..3735897c 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -786,15 +786,16 @@ async def get_input(self) -> Any: When running locally and the default key-value store holds no input record, the input is read from a `` or `.json` file, looked up first in the current working directory and then in the - storage directory (`Configuration.storage_dir`). The `.json` file is parsed as JSON; the bare file is parsed as - JSON when it is valid JSON, and returned as `bytes` otherwise. + default key-value store's directory under the storage directory, where `apify run` writes it. The latter only + matters for storage clients that don't read that directory themselves. The `.json` file is parsed as JSON; the + bare file is parsed as JSON when it is valid JSON, and returned as `bytes` otherwise. Returns: The Actor input, usually a `dict` of input fields, or `None` if the Actor has no input. Raises: - RuntimeError: If both `` and `.json` exist in the working or storage directory. - ValueError: If the `.json` file in the working or storage directory is not valid JSON. + RuntimeError: If both `` and `.json` exist in the same directory. + ValueError: If the `.json` file is not valid JSON. """ input_key = self.configuration.input_key key_value_store = await self.open_key_value_store() @@ -802,8 +803,14 @@ async def get_input(self) -> Any: if input_value is None and not self.is_at_home() and not await key_value_store.record_exists(input_key): input_path = await self._find_input_file(input_key, Path.cwd(), 'working directory') if input_path is None: + # Mirrors apify-cli's `getLocalKeyValueStorePath`, which is where `apify run` writes the input. + default_kvs_dir = ( + Path(self.configuration.storage_dir) + / 'key_value_stores' + / (self.configuration.default_key_value_store_id or 'default') + ) input_path = await self._find_input_file( - input_key, Path(self.configuration.storage_dir), 'storage directory' + input_key, default_kvs_dir, 'default key-value store directory' ) if input_path is not None: input_value = await self._read_input_file(input_path, input_key) diff --git a/tests/unit/actor/test_actor_key_value_store.py b/tests/unit/actor/test_actor_key_value_store.py index 4a5ad456..ebd1eb87 100644 --- a/tests/unit/actor/test_actor_key_value_store.py +++ b/tests/unit/actor/test_actor_key_value_store.py @@ -4,12 +4,15 @@ import pytest +from crawlee import service_locator from crawlee._utils.file import json_dumps +from crawlee.storage_clients import MemoryStorageClient from ..._utils import PRIVATE_KEY_PASSWORD, PRIVATE_KEY_PEM_BASE64, PUBLIC_KEY, poll_until_condition -from apify import Actor +from apify import Actor, Configuration from apify._consts import ENCRYPTED_JSON_VALUE_PREFIX, ENCRYPTED_STRING_VALUE_PREFIX, ActorEnvVars, ApifyEnvVars from apify._crypto import public_encrypt +from apify.storage_clients import SmartApifyStorageClient async def test_open_returns_same_references() -> None: @@ -269,58 +272,83 @@ async def test_get_input_raises_on_multiple_working_directory_files( await actor.get_input() -async def test_get_input_falls_back_to_storage_directory(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Without an input record, `INPUT.json` in the storage directory is read after the working directory.""" - working_dir = tmp_path / 'working_dir' - working_dir.mkdir() - monkeypatch.chdir(working_dir) - (tmp_path / 'INPUT.json').write_text('{"from": "storage"}') +def _use_memory_storage(store_id: str = 'default') -> Path: + """Switch to in-memory storage, which never reads the storage directory, and create the default store dir.""" + service_locator.set_storage_client(SmartApifyStorageClient(local_storage_client=MemoryStorageClient())) + default_kvs_dir = Path(Configuration.get_global_configuration().storage_dir) / 'key_value_stores' / store_id + default_kvs_dir.mkdir(parents=True) + return default_kvs_dir + + +async def test_get_input_falls_back_to_default_kvs_directory(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Without an input record, `INPUT.json` in the default store directory is read after the working directory.""" + monkeypatch.chdir(tmp_path) + (_use_memory_storage() / 'INPUT.json').write_text('{"from": "storage"}') async with Actor as actor: assert await actor.get_input() == {'from': 'storage'} - (working_dir / 'INPUT.json').write_text('{"from": "cwd"}') + (tmp_path / 'INPUT.json').write_text('{"from": "cwd"}') assert await actor.get_input() == {'from': 'cwd'} -async def test_get_input_null_working_directory_file_skips_storage_directory( - monkeypatch: pytest.MonkeyPatch, tmp_path: Path -) -> None: - """A working-directory file holding JSON `null` counts as present, so the storage directory is not read.""" +async def test_get_input_skips_storage_directory_root(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """An input file in the root of the storage directory is not read.""" working_dir = tmp_path / 'working_dir' working_dir.mkdir() monkeypatch.chdir(working_dir) - (working_dir / 'INPUT.json').write_text('null') - (tmp_path / 'INPUT.json').write_text('{"from": "storage"}') + _use_memory_storage() + (tmp_path / 'INPUT.json').write_text('{"from": "storage root"}') async with Actor as actor: assert await actor.get_input() is None -async def test_get_input_raises_on_multiple_storage_directory_files( +async def test_get_input_default_kvs_directory_follows_store_id( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """Both `INPUT` and `INPUT.json` in the storage directory raise an error naming the storage directory.""" - working_dir = tmp_path / 'working_dir' - working_dir.mkdir() - monkeypatch.chdir(working_dir) - (tmp_path / 'INPUT').write_text('{}') - (tmp_path / 'INPUT.json').write_text('{}') + """The default store directory is named after the configured default key-value store ID.""" + monkeypatch.setenv(ActorEnvVars.DEFAULT_KEY_VALUE_STORE_ID, 'custom') + monkeypatch.chdir(tmp_path) + (_use_memory_storage('custom') / 'INPUT.json').write_text('{"from": "custom store"}') async with Actor as actor: - with pytest.raises(RuntimeError, match='multiple input files in the storage directory'): + assert await actor.get_input() == {'from': 'custom store'} + + +async def test_get_input_null_working_directory_file_skips_default_kvs_directory( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """A working-directory file holding JSON `null` counts as present, so the default store directory is not read.""" + monkeypatch.chdir(tmp_path) + (tmp_path / 'INPUT.json').write_text('null') + (_use_memory_storage() / 'INPUT.json').write_text('{"from": "storage"}') + + async with Actor as actor: + assert await actor.get_input() is None + + +async def test_get_input_raises_on_multiple_default_kvs_directory_files( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Both `INPUT` and `INPUT.json` in the default store directory raise an error naming that directory.""" + monkeypatch.chdir(tmp_path) + default_kvs_dir = _use_memory_storage() + (default_kvs_dir / 'INPUT').write_text('{}') + (default_kvs_dir / 'INPUT.json').write_text('{}') + + async with Actor as actor: + with pytest.raises(RuntimeError, match='multiple input files in the default key-value store directory'): await actor.get_input() async def test_get_input_skips_input_files_on_platform(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """On the Apify platform, neither the working directory nor the storage directory is read.""" - working_dir = tmp_path / 'working_dir' - working_dir.mkdir() - monkeypatch.chdir(working_dir) - (working_dir / 'INPUT.json').write_text('{"from": "cwd"}') + """On the Apify platform, neither the working directory nor the default store directory is read.""" + monkeypatch.chdir(tmp_path) + (tmp_path / 'INPUT.json').write_text('{"from": "cwd"}') + (_use_memory_storage() / 'INPUT.json').write_text('{"from": "storage"}') async with Actor as actor: - (Path(actor.configuration.storage_dir) / 'INPUT.json').write_text('{"from": "storage"}') monkeypatch.setattr(actor, 'is_at_home', lambda: True) assert await actor.get_input() is None From 8443f751c227935cc9cc72e6c81c545131e7264a Mon Sep 17 00:00:00 2001 From: Apify AI Factory Date: Fri, 23 Oct 2026 09:57:01 +0200 Subject: [PATCH 4/4] fix: raise when the local input is both in the default key-value store and in the working directory Mirrors apify/apify-sdk-js@4fabbeb (apify-sdk-js#826). Locally, an input in the default key-value store (a record, or a file in its directory under the storage directory) next to an INPUT / INPUT.json file in the working directory now raises a RuntimeError instead of picking one. In 4.1.0 the store record silently won over the working-directory file, so a local project that has both files now fails with an error telling the user to keep only one. Runs on the Apify platform are not affected. --- docs/02_concepts/02_actor_input.mdx | 2 +- src/apify/_actor.py | 31 +++++++++++++------ .../unit/actor/test_actor_key_value_store.py | 30 +++++++++++------- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/docs/02_concepts/02_actor_input.mdx b/docs/02_concepts/02_actor_input.mdx index e8979d98..f46d2965 100644 --- a/docs/02_concepts/02_actor_input.mdx +++ b/docs/02_concepts/02_actor_input.mdx @@ -22,7 +22,7 @@ For example, if an Actor received a JSON input with two fields, `{ "firstNumber" ## Input file in the working directory -When you run the Actor locally and its default key-value store holds no input record, `Actor.get_input` reads the input from an `INPUT.json` or a bare `INPUT` file in the current working directory, and then in the default key-value store directory (`/key_value_stores/`, `storage/key_value_stores/default` by default, where `apify run` writes it). The latter covers storage clients that don't read that directory, such as in-memory storage. The working directory takes precedence. The file name follows the configured input key. The `.json` file is parsed as JSON. The bare file is parsed as JSON when it's valid JSON and returned as `bytes` otherwise. If both files exist in the same directory, `Actor.get_input` raises an error. On the Apify platform, neither directory is read. +When you run the Actor locally, `Actor.get_input` also accepts an `INPUT.json` or a bare `INPUT` file in the current working directory. The file name follows the configured input key. The `.json` file is parsed as JSON. The bare file is parsed as JSON when it's valid JSON and returned as `bytes` otherwise. With a storage client that doesn't read the default key-value store directory itself, such as in-memory storage, the same file is also picked up from `/key_value_stores/` (`storage/key_value_stores/default` by default), where `apify run` writes it. The input must be in exactly one place: both files in one directory, or a file in the working directory next to an input in the default key-value store, make `Actor.get_input` raise an error instead of picking one. On the Apify platform, the working directory is never read. ## Validating input diff --git a/src/apify/_actor.py b/src/apify/_actor.py index 3735897c..6500b429 100644 --- a/src/apify/_actor.py +++ b/src/apify/_actor.py @@ -784,25 +784,28 @@ async def get_input(self) -> Any: a `dict` keyed by the fields declared in the Actor's input schema. Any secret input fields are decrypted to plaintext before being returned. - When running locally and the default key-value store holds no input record, the input is read from a - `` or `.json` file, looked up first in the current working directory and then in the - default key-value store's directory under the storage directory, where `apify run` writes it. The latter only - matters for storage clients that don't read that directory themselves. The `.json` file is parsed as JSON; the - bare file is parsed as JSON when it is valid JSON, and returned as `bytes` otherwise. + When running locally, the input may instead be a `` or `.json` file in the current + working directory. A storage client that doesn't read the default key-value store's directory under the + storage directory (e.g. in-memory storage) also gets the file `apify run` writes there. The input must be in + exactly one place. The `.json` file is parsed as JSON; the bare file is parsed as JSON when it is valid JSON, + and returned as `bytes` otherwise. Returns: The Actor input, usually a `dict` of input fields, or `None` if the Actor has no input. Raises: - RuntimeError: If both `` and `.json` exist in the same directory. + RuntimeError: If the input is found in more than one place: both `` and `.json` in + one directory, or in the default key-value store and in the working directory. ValueError: If the `.json` file is not valid JSON. """ input_key = self.configuration.input_key key_value_store = await self.open_key_value_store() input_value = await key_value_store.get_value(input_key) - if input_value is None and not self.is_at_home() and not await key_value_store.record_exists(input_key): - input_path = await self._find_input_file(input_key, Path.cwd(), 'working directory') - if input_path is None: + if not self.is_at_home(): + # Not a conflict check: under the file-system storage client the record *is* the store directory's file. + store_has_input = input_value is not None or await key_value_store.record_exists(input_key) + input_path = None + if not store_has_input: # Mirrors apify-cli's `getLocalKeyValueStorePath`, which is where `apify run` writes the input. default_kvs_dir = ( Path(self.configuration.storage_dir) @@ -812,6 +815,16 @@ async def get_input(self) -> Any: input_path = await self._find_input_file( input_key, default_kvs_dir, 'default key-value store directory' ) + + cwd_input_path = await self._find_input_file(input_key, Path.cwd(), 'working directory') + if cwd_input_path is not None: + if store_has_input or input_path is not None: + raise RuntimeError( + f'Found the input both in the default key-value store and in a "{input_key}" or ' + f'"{input_key}.json" file in the working directory. Keep only one of them.' + ) + input_path = cwd_input_path + if input_path is not None: input_value = await self._read_input_file(input_path, input_key) diff --git a/tests/unit/actor/test_actor_key_value_store.py b/tests/unit/actor/test_actor_key_value_store.py index ebd1eb87..4f75f81a 100644 --- a/tests/unit/actor/test_actor_key_value_store.py +++ b/tests/unit/actor/test_actor_key_value_store.py @@ -235,28 +235,30 @@ async def test_get_input_working_directory_file_follows_input_key( assert await actor.get_input() == {'from': 'cli'} -async def test_get_input_prefers_store_record_over_working_directory( +async def test_get_input_raises_on_store_record_and_working_directory_file( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """An input record in the default key-value store wins over a file in the working directory.""" + """An input record in the default key-value store next to a file in the working directory raises an error.""" monkeypatch.chdir(tmp_path) (tmp_path / 'INPUT.json').write_text('{"from": "file"}') async with Actor as actor: await actor.set_value('INPUT', {'from': 'store'}) - assert await actor.get_input() == {'from': 'store'} + with pytest.raises(RuntimeError, match='both in the default key-value store and in'): + await actor.get_input() -async def test_get_input_null_store_record_skips_working_directory( +async def test_get_input_null_store_record_conflicts_with_working_directory_file( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """An input record holding JSON `null` counts as present, so the working-directory file is not read.""" + """An input record holding JSON `null` counts as present, so a working-directory file next to it raises.""" monkeypatch.chdir(tmp_path) (tmp_path / 'INPUT.json').write_text('{"from": "file"}') async with Actor as actor: await actor.set_value('INPUT', None) - assert await actor.get_input() is None + with pytest.raises(RuntimeError, match='both in the default key-value store and in'): + await actor.get_input() async def test_get_input_raises_on_multiple_working_directory_files( @@ -280,8 +282,10 @@ def _use_memory_storage(store_id: str = 'default') -> Path: return default_kvs_dir -async def test_get_input_falls_back_to_default_kvs_directory(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: - """Without an input record, `INPUT.json` in the default store directory is read after the working directory.""" +async def test_get_input_reads_default_kvs_directory_and_rejects_working_directory_file( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Without an input record, the default store directory is read; a working-directory file next to it raises.""" monkeypatch.chdir(tmp_path) (_use_memory_storage() / 'INPUT.json').write_text('{"from": "storage"}') @@ -289,7 +293,8 @@ async def test_get_input_falls_back_to_default_kvs_directory(monkeypatch: pytest assert await actor.get_input() == {'from': 'storage'} (tmp_path / 'INPUT.json').write_text('{"from": "cwd"}') - assert await actor.get_input() == {'from': 'cwd'} + with pytest.raises(RuntimeError, match='both in the default key-value store and in'): + await actor.get_input() async def test_get_input_skips_storage_directory_root(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: @@ -316,16 +321,17 @@ async def test_get_input_default_kvs_directory_follows_store_id( assert await actor.get_input() == {'from': 'custom store'} -async def test_get_input_null_working_directory_file_skips_default_kvs_directory( +async def test_get_input_null_working_directory_file_conflicts_with_default_kvs_directory( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - """A working-directory file holding JSON `null` counts as present, so the default store directory is not read.""" + """A working-directory file holding JSON `null` counts as present, so a default store directory file raises.""" monkeypatch.chdir(tmp_path) (tmp_path / 'INPUT.json').write_text('null') (_use_memory_storage() / 'INPUT.json').write_text('{"from": "storage"}') async with Actor as actor: - assert await actor.get_input() is None + with pytest.raises(RuntimeError, match='both in the default key-value store and in'): + await actor.get_input() async def test_get_input_raises_on_multiple_default_kvs_directory_files(