From 2bca869e328dd61ebecee7ceb6bc2ea707651fd6 Mon Sep 17 00:00:00 2001
From: Nick Sweeting
Date: Sun, 31 May 2026 23:15:17 -0700
Subject: [PATCH] Stabilize frozen config CLI test flows
---
archivebox/api/v1_cli.py | 8 +
archivebox/api/v1_crawls.py | 3 +-
archivebox/cli/archivebox_add.py | 19 +-
archivebox/cli/archivebox_init.py | 2 +
archivebox/cli/archivebox_install.py | 9 +-
archivebox/config/common.py | 208 +++++++++++++++---
archivebox/config/ldap.py | 3 +-
archivebox/config/views.py | 2 +-
archivebox/core/forms.py | 4 +-
archivebox/core/models.py | 13 +-
archivebox/core/settings_logging.py | 2 +
archivebox/core/views.py | 41 ++--
.../0018_freeze_crawl_config_snapshots.py | 33 +++
.../migrations/0019_crawlschedule_config.py | 25 +++
archivebox/crawls/models.py | 21 +-
archivebox/hooks.py | 3 +-
archivebox/services/process_service.py | 7 +-
archivebox/services/runner.py | 21 +-
archivebox/tests/conftest.py | 2 +-
archivebox/tests/fixtures.py | 1 +
archivebox/tests/test_cli_add.py | 2 +-
archivebox/tests/test_cli_piping.py | 10 +
archivebox/tests/test_frozen_crawl_config.py | 200 +++++++++++++++++
archivebox/tests/test_ui_add_view.py | 16 +-
bin/test.sh | 3 +-
25 files changed, 540 insertions(+), 118 deletions(-)
create mode 100644 archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py
create mode 100644 archivebox/crawls/migrations/0019_crawlschedule_config.py
create mode 100644 archivebox/tests/test_frozen_crawl_config.py
diff --git a/archivebox/api/v1_cli.py b/archivebox/api/v1_cli.py
index 2ad48699..b1630b74 100644
--- a/archivebox/api/v1_cli.py
+++ b/archivebox/api/v1_cli.py
@@ -66,6 +66,8 @@ class AddCommandSchema(Schema):
parser: str = "auto"
plugins: str = ""
only_new: bool | None = None
+ update: bool = False
+ overwrite: bool = False
index_only: bool = False
@@ -90,6 +92,8 @@ class ScheduleCommandSchema(Schema):
tag: str = ""
depth: int = 0
only_new: bool | None = None
+ update: bool = False
+ overwrite: bool = False
clear: bool = False
@@ -120,6 +124,8 @@ def cli_add(request: HttpRequest, args: AddCommandSchema):
config_overrides: dict[str, object] = {}
if args.only_new is not None:
config_overrides["ONLY_NEW"] = bool(args.only_new)
+ if args.update or args.overwrite:
+ config_overrides["ONLY_NEW"] = False
crawl, snapshots = add(
urls=args.urls,
snapshot_ids=args.snapshot_ids,
@@ -189,6 +195,8 @@ def cli_schedule(request: HttpRequest, args: ScheduleCommandSchema):
config_overrides: dict[str, object] = {}
if args.only_new is not None:
config_overrides["ONLY_NEW"] = bool(args.only_new)
+ if args.update or args.overwrite:
+ config_overrides["ONLY_NEW"] = False
result = schedule(
import_path=args.import_path,
add=args.add,
diff --git a/archivebox/api/v1_crawls.py b/archivebox/api/v1_crawls.py
index 32c1006b..e0f602b9 100644
--- a/archivebox/api/v1_crawls.py
+++ b/archivebox/api/v1_crawls.py
@@ -122,7 +122,8 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
tags = normalize_tag_list(data.tags, data.tags_str)
config = dict(data.config or {})
- config.setdefault("PERMISSIONS", str(get_config(user=request.user).PERMISSIONS))
+ request_user = request.user if request.user.is_authenticated else None
+ config.setdefault("PERMISSIONS", str(get_config(user=request_user).PERMISSIONS))
crawl = Crawl.objects.create(
urls="\n".join(urls),
max_depth=data.max_depth,
diff --git a/archivebox/cli/archivebox_add.py b/archivebox/cli/archivebox_add.py
index 1db8e93f..016415a3 100644
--- a/archivebox/cli/archivebox_add.py
+++ b/archivebox/cli/archivebox_add.py
@@ -185,7 +185,7 @@ def add(
label=f"{USER}@{HOSTNAME} $ {cmd_str} [{timestamp}]",
created_by_id=created_by_id,
status=Crawl.StatusChoices.QUEUED,
- retry_at=None if index_only else timezone.now(),
+ retry_at=None if (index_only or bg) else timezone.now(),
config=crawl_config,
)
@@ -198,15 +198,8 @@ def add(
# Discovered URLs become child Snapshots (depth+1)
if index_only:
- # ``--index-only`` means "add the URLs to the index without archiving
- # them now". That only holds if we actually materialize the Snapshot
- # rows here — otherwise the CLI returns success with nothing in the
- # index, which broke ``test_add_url_after_init`` & friends. Create
- # the Snapshots synchronously (the same step the runner would do)
- # but skip starting any worker so extractors don't run.
- crawl.create_snapshots_from_urls()
- print("[yellow]\\[*] Index-only mode - URLs indexed, runner not started[/yellow]")
- return crawl, crawl.snapshot_set.all()
+ print("[yellow]\\[*] Index-only mode - URLs queued, runner not started[/yellow]")
+ return crawl, crawl.snapshot_set.none()
# 5. Start the crawl runner to process the queue
# The runner will:
@@ -330,6 +323,8 @@ def add(
"Pass --no-only-new to force re-archive of URLs that already exist.",
)
@click.option("--index-only", is_flag=True, help="Just add the URLs to the index without archiving them now")
+@click.option("--overwrite", is_flag=True, help="Re-archive URLs even if they already exist (alias for --no-only-new)")
+@click.option("--update", is_flag=True, help="Re-archive URLs even if they already exist (alias for --no-only-new)")
@click.option("--bg", is_flag=True, help="Run archiving in background (queue work and return immediately)")
@click.argument("urls", nargs=-1, type=click.Path())
@docstring(add.__doc__)
@@ -360,7 +355,11 @@ def main(**kwargs):
# Translate --only-new/--no-only-new into a crawl config override.
# add() takes config overrides as a dict; no per-flag kwargs.
+ overwrite = kwargs.pop("overwrite", False)
+ update = kwargs.pop("update", False)
only_new = kwargs.pop("only_new", None)
+ if overwrite or update:
+ only_new = False
if only_new is not None:
kwargs["config"] = {"ONLY_NEW": bool(only_new)}
diff --git a/archivebox/cli/archivebox_init.py b/archivebox/cli/archivebox_init.py
index 957b78ee..87bc05f2 100755
--- a/archivebox/cli/archivebox_init.py
+++ b/archivebox/cli/archivebox_init.py
@@ -73,6 +73,8 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
config.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
config.USERS_DIR.mkdir(parents=True, exist_ok=True)
Path(CONSTANTS.LOGS_DIR).mkdir(exist_ok=True)
+ for path in (Path(CONSTANTS.SOURCES_DIR), config.ARCHIVE_DIR, config.USERS_DIR, Path(CONSTANTS.LOGS_DIR)):
+ path.chmod(int(config.OUTPUT_PERMISSIONS, base=8) | 0o111)
print(f" + {_display_data_path(CONSTANTS.CONFIG_FILE, DATA_DIR)}...")
diff --git a/archivebox/cli/archivebox_install.py b/archivebox/cli/archivebox_install.py
index 781c7024..6817e345 100755
--- a/archivebox/cli/archivebox_install.py
+++ b/archivebox/cli/archivebox_install.py
@@ -28,6 +28,11 @@ def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bo
config = get_config()
archive_dir = config.ARCHIVE_DIR
+
+ if dry_run:
+ print("[dim]Dry run - would detect ArchiveBox dependencies and run the abx-dl install flow[/dim]")
+ return
+
if not (os.access(archive_dir, os.R_OK) and archive_dir.is_dir()):
init() # must init full index because we need a db to store Binary entries in
@@ -47,10 +52,6 @@ def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bo
print(f" DATA_DIR will be owned by [blue]{ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP}[/blue].")
print()
- if dry_run:
- print("[dim]Dry run - would run the abx-dl install flow[/dim]")
- return
-
# Set up Django
from archivebox.config.django import setup_django
diff --git a/archivebox/config/common.py b/archivebox/config/common.py
index e5bbc79f..adb992db 100644
--- a/archivebox/config/common.py
+++ b/archivebox/config/common.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
__package__ = "archivebox.config"
import json
@@ -13,7 +15,7 @@ from typing import Any, ClassVar, cast
from pathlib import Path
from rich.console import Console
-from pydantic import BaseModel, Field, create_model, field_validator, model_validator
+from pydantic import BaseModel, Field, PrivateAttr, create_model, field_validator, model_validator
from pydantic_settings import SettingsConfigDict
from abx_plugins.plugins.base.utils import BASE_CONFIG_PATH, build_config_model, resolve_plugin_configs
@@ -64,6 +66,18 @@ def permissions_from_legacy_public_flags(raw_config: Mapping[str, object]) -> st
_SENSITIVE_CONFIG_KEY_NEEDLES = ("TOKEN", "SECRET", "API_KEY", "APIKEY", "PASSWORD")
SENSITIVE_CONFIG_VALUE_REDACTED = "********"
+_SCOPE_CRAWL_FROZEN = "crawl_frozen"
+_SCOPE_CRAWL_EXECUTION = "crawl_execution"
+_SCOPE_SERVER = "server"
+
+
+
+def _plugin_sensitive_config_keys() -> set[str]:
+ sensitive_keys: set[str] = set()
+ for prop_key, prop_schema in _plugin_config_properties(PLUGIN_CONFIG_SCHEMAS).items():
+ if isinstance(prop_schema, Mapping) and prop_schema.get("x-sensitive"):
+ sensitive_keys.add(str(prop_key))
+ return sensitive_keys
def is_sensitive_config_key(key: str) -> bool:
@@ -77,8 +91,9 @@ def is_sensitive_config_key(key: str) -> bool:
REST API responses, and any future surface that round-trips raw config
values all agree on which keys to redact.
"""
- upper = (key or "").upper()
- return any(needle in upper for needle in _SENSITIVE_CONFIG_KEY_NEEDLES)
+ key = str(key or "")
+ upper = key.upper()
+ return key in _plugin_sensitive_config_keys() or any(needle in upper for needle in _SENSITIVE_CONFIG_KEY_NEEDLES)
def redact_sensitive_config(config: Mapping[str, Any] | None) -> dict[str, Any]:
@@ -103,6 +118,34 @@ def redact_sensitive_config(config: Mapping[str, Any] | None) -> dict[str, Any]:
return redacted
+
+def normalize_runtime_config(config: BaseConfigSet | Mapping[str, Any] | str | None) -> dict[str, Any]:
+ """Return a JSON-safe config dict suitable for storage or event payloads."""
+ if config is None:
+ return {}
+ if isinstance(config, BaseConfigSet):
+ config = config.model_dump(mode="json")
+ elif isinstance(config, str):
+ config = json.loads(config)
+ else:
+ config = dict(config)
+ return {key: value for key, value in json.loads(json.dumps(config, default=str)).items() if value is not None}
+
+
+def build_crawl_config_snapshot(
+ *,
+ user: Any = None,
+ persona: Any = None,
+ overrides: Mapping[str, Any] | None = None,
+ base_config: ArchiveBoxBaseConfig | Mapping[str, object] | None = None,
+) -> dict[str, Any]:
+ """Build the frozen runtime config stored on Crawl.config at creation time."""
+ effective = get_config(user=user, persona=persona, base_config=base_config, include_machine=False)
+ frozen = effective.for_crawl_frozen()
+ if overrides:
+ frozen = get_config(base_config=frozen, overrides=overrides, include_machine=False).for_crawl_frozen()
+ return frozen
+
def rprint(*args, file=None, **kwargs):
console = _STDERR_CONSOLE if file is sys.stderr else _STDOUT_CONSOLE
console.print(*args, **kwargs)
@@ -110,6 +153,7 @@ def rprint(*args, file=None, **kwargs):
class ShellConfig(BaseConfigSet):
toml_section_header: str = "SHELL_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_CRAWL_EXECUTION)
DEBUG: bool = Field(default="--debug" in sys.argv)
@@ -141,6 +185,7 @@ class ShellConfig(BaseConfigSet):
class StorageConfig(BaseConfigSet):
toml_section_header: str = "STORAGE_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_SERVER)
# ARCHIVE_DIR / USERS_DIR are resolved dynamically via get_config().
ARCHIVE_DIR: Path = Field(default=CONSTANTS.ARCHIVE_DIR)
@@ -150,34 +195,36 @@ class StorageConfig(BaseConfigSet):
# TMP_DIR must be a local, fast, readable/writable dir by archivebox user,
# must be a short path due to unix path length restrictions for socket files (<100 chars)
# must be a local SSD/tmpfs for speed and because bind mounts/network mounts/FUSE dont support unix sockets
- TMP_DIR: Path = Field(default=CONSTANTS.DEFAULT_TMP_DIR)
+ TMP_DIR: Path = Field(default=CONSTANTS.DEFAULT_TMP_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
# LIB_DIR must be a local, fast, readable/writable dir by archivebox user,
# must be able to contain executable binaries (up to 5GB size)
# should not be a remote/network/FUSE mount for speed reasons, otherwise extractors will be slow
- LIB_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_DIR)
+ LIB_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
# LIB_BIN_DIR is an optional human-facing symlink convenience directory.
# Runtime lookup must use provider-specific paths under LIB_DIR instead.
- LIB_BIN_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_BIN_DIR)
+ LIB_BIN_DIR: Path = Field(default=CONSTANTS.DEFAULT_LIB_BIN_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
# CUSTOM_TEMPLATES_DIR allows users to override default templates
# defaults to DATA_DIR / 'user_templates' but can be configured
CUSTOM_TEMPLATES_DIR: Path = Field(default=CONSTANTS.CUSTOM_TEMPLATES_DIR)
- OUTPUT_PERMISSIONS: str = Field(default="644")
+ OUTPUT_PERMISSIONS: str = Field(default="644", json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
ENFORCE_ATOMIC_WRITES: bool = Field(default=True)
ALLOW_NO_UNIX_SOCKETS: bool = Field(default=False, alias="ARCHIVEBOX_ALLOW_NO_UNIX_SOCKETS")
class GeneralConfig(BaseConfigSet):
toml_section_header: str = "GENERAL_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_SERVER)
TAG_SEPARATOR_PATTERN: str = Field(default=r"[,]")
class ServerConfig(BaseConfigSet):
toml_section_header: str = "SERVER_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_SERVER)
SERVER_SECURITY_MODES: ClassVar[tuple[str, ...]] = (
"safe-subdomains-fullreplay",
@@ -258,6 +305,7 @@ class ServerConfig(BaseConfigSet):
class DatabaseConfig(BaseConfigSet):
toml_section_header: str = "DATABASE_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_SERVER)
DATABASE_NAME: str = Field(default=str(CONSTANTS.DATABASE_FILE), alias="ARCHIVEBOX_DATABASE_NAME")
SQLITE_JOURNAL_MODE: str = Field(
@@ -277,6 +325,7 @@ class DatabaseConfig(BaseConfigSet):
class ArchivingConfig(BaseConfigSet):
toml_section_header: str = "ARCHIVING_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_CRAWL_FROZEN)
PLUGINS: str = Field(
default="",
@@ -284,6 +333,7 @@ class ArchivingConfig(BaseConfigSet):
)
ONLY_NEW: bool = Field(default=True)
+ INDEX_ONLY: bool = Field(default=False)
TIMEOUT: int = Field(default=60)
CRAWL_MAX_URLS: int = Field(default=0)
@@ -397,6 +447,7 @@ def parse_delete_after(value) -> timedelta | None:
class SearchBackendConfig(BaseConfigSet):
toml_section_header: str = "SEARCH_BACKEND_CONFIG"
+ _scope: str = PrivateAttr(default=_SCOPE_SERVER)
SEARCH_BACKEND_ENGINE: str = Field(default="ripgrep")
@@ -473,12 +524,93 @@ class ArchiveBoxBaseConfig(
populate_by_name=True,
)
- DATA_DIR: Path = Field(default=CONSTANTS.DATA_DIR)
- ABX_RUNTIME: str = Field(default="archivebox")
- CRAWL_DIR: Path | None = Field(default=None)
- SNAP_DIR: Path | None = Field(default=None)
+ DATA_DIR: Path = Field(default=CONSTANTS.DATA_DIR, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
+ ABX_RUNTIME: str = Field(default="archivebox", json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
+ CRAWL_DIR: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
+ SNAP_DIR: Path | None = Field(default=None, json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
computed_config_keys: ClassVar[tuple[str, ...]] = COMPUTED_CONFIG_KEYS
+ @classmethod
+ def _core_config_classes(cls) -> tuple[type[BaseConfigSet], ...]:
+ return (
+ ShellConfig,
+ StorageConfig,
+ GeneralConfig,
+ ServerConfig,
+ DatabaseConfig,
+ ArchivingConfig,
+ SearchBackendConfig,
+ LDAPConfig,
+ )
+
+ @classmethod
+ def _core_field_scope(cls, key: str) -> str | None:
+ if key == "toml_section_header":
+ return _SCOPE_SERVER
+ for config_cls in cls._core_config_classes():
+ field = config_cls.model_fields.get(key)
+ if field is None:
+ continue
+ default_scope = str(config_cls.__private_attributes__["_scope"].default)
+ extra = field.json_schema_extra
+ if isinstance(extra, dict) and "scope" in extra:
+ return str(extra["scope"])
+ return default_scope
+ if key in ArchiveBoxBaseConfig.model_fields:
+ field = ArchiveBoxBaseConfig.model_fields[key]
+ extra = field.json_schema_extra
+ if isinstance(extra, dict) and "scope" in extra:
+ return str(extra["scope"])
+ return _SCOPE_SERVER
+ return None
+
+ @classmethod
+ def _plugin_field_scope(cls, key: str) -> str | None:
+ for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items():
+ properties = schema.get("properties") if isinstance(schema, dict) else None
+ if not isinstance(properties, dict) or key not in properties:
+ continue
+ prop_schema = properties.get(key) or {}
+ if isinstance(prop_schema, Mapping) and prop_schema.get("x-scope"):
+ return str(prop_schema["x-scope"])
+ if str(plugin_name).startswith("search_backend_"):
+ return _SCOPE_SERVER
+ upper = key.upper()
+ if (
+ upper == "PATH"
+ or upper.endswith("_PATH")
+ or upper.endswith("_DIR")
+ or upper.endswith("_BINARY")
+ or upper.endswith("_CACHE")
+ or upper.endswith("_COVERAGE")
+ or upper.endswith("_PYTHON")
+ ):
+ return _SCOPE_CRAWL_EXECUTION
+ return _SCOPE_CRAWL_FROZEN
+ return None
+
+ @classmethod
+ def scope_for_key(cls, key: str) -> str:
+ return cls._core_field_scope(key) or cls._plugin_field_scope(key) or _SCOPE_SERVER
+
+ def _scoped_config(self, *, include_execution: bool) -> dict[str, Any]:
+ allowed_scopes = {_SCOPE_CRAWL_FROZEN}
+ if include_execution:
+ allowed_scopes.add(_SCOPE_CRAWL_EXECUTION)
+ return {
+ key: value
+ for key, value in normalize_runtime_config(self).items()
+ if type(self).scope_for_key(key) in allowed_scopes
+ }
+
+ def for_crawl_execution(self) -> dict[str, Any]:
+ """Config safe to pass to crawl/snapshot hook execution."""
+ return self._scoped_config(include_execution=True)
+
+ def for_crawl_frozen(self) -> dict[str, Any]:
+ """Config safe to persist permanently on Crawl.config."""
+ return self._scoped_config(include_execution=False)
+
@model_validator(mode="after")
def resolve_runtime_paths(self):
self.DATA_DIR = self.DATA_DIR.expanduser().resolve()
@@ -552,12 +684,12 @@ def get_config(
1. Explicit overrides
2. Per-ArchiveResult config
3. Per-snapshot config and output path
- 4. Per-crawl config and output path
- 5. Per-user config
- 6. Per-persona derived config
- 7. Current machine derived config
- 8. Environment variables
- 9. Config file (ArchiveBox.conf)
+ 4. Frozen per-crawl config and output path
+ 5. Per-user config (only when resolving outside a crawl)
+ 6. Per-persona derived config (only when resolving outside a crawl)
+ 7. Current machine derived config (only when resolving outside a crawl)
+ 8. Environment variables (only when resolving outside a crawl)
+ 9. Config file (ArchiveBox.conf, only when resolving outside a crawl)
10. Plugin schema defaults
11. Core config defaults
"""
@@ -567,7 +699,9 @@ def get_config(
if crawl is None and snapshot is not None:
crawl = snapshot.crawl
- if include_machine and machine is None:
+ crawl_config_base = crawl is not None and base_config is None
+
+ if include_machine and machine is None and not crawl_config_base:
try:
from django.apps import apps
@@ -578,15 +712,19 @@ def get_config(
except Exception:
machine = None
- if persona is None and crawl is not None:
+ if persona is None and crawl is not None and not crawl_config_base:
persona = crawl.resolve_persona()
config_data: ConfigPayload = dict(defaults or {})
- if base_config is not None:
+ base_config_payload: ConfigPayload = {}
+ if crawl_config_base:
+ config_data.update(dict(crawl.config or {}))
+ elif base_config is not None:
if isinstance(base_config, ArchiveBoxBaseConfig):
- config_data.update(base_config.model_dump(mode="json"))
+ base_config_payload.update(base_config.model_dump(mode="json"))
else:
- config_data.update(dict(base_config))
+ base_config_payload.update(dict(base_config))
+ config_data.update(base_config_payload)
else:
config_data.update(ArchiveBoxConfig().model_dump(mode="json"))
legacy_permissions = permissions_from_legacy_public_flags({**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **os.environ})
@@ -595,19 +733,16 @@ def get_config(
scope_overrides: ConfigPayload = {}
- if include_machine and machine is not None and machine.config:
- from archivebox.machine.models import _sanitize_machine_config
+ if not crawl_config_base:
+ if include_machine and machine is not None and machine.config:
+ from archivebox.machine.models import _sanitize_machine_config
- scope_overrides.update(_sanitize_machine_config(machine.config, lib_dir=config_data.get("LIB_DIR")))
+ scope_overrides.update(_sanitize_machine_config(machine.config, lib_dir=config_data.get("LIB_DIR")))
- if persona is not None:
- scope_overrides.update(persona.get_derived_config())
+ if persona is not None:
+ scope_overrides.update(persona.get_derived_config())
- user_config = getattr(user, "config", None)
- if user_config:
- scope_overrides.update(user_config)
-
- if crawl is not None and crawl.config:
+ if crawl is not None and crawl.config and not crawl_config_base:
scope_overrides.update(crawl.config)
if crawl is not None:
@@ -638,13 +773,20 @@ def get_config(
plugin_name: schema.get("properties", {}) for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items() if isinstance(schema, dict)
}
plugin_global_config = {key: str(value) if isinstance(value, Path) else value for key, value in config_data.items()}
+ plugin_user_config = _plugin_user_config(scope_overrides)
+ if not crawl_config_base:
+ plugin_user_config = {**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **plugin_user_config}
plugin_sections = resolve_plugin_configs(
plugin_schemas,
global_config=plugin_global_config,
- user_config={**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **_plugin_user_config(scope_overrides)},
+ user_config=plugin_user_config,
)
for plugin_config in plugin_sections.values():
config_data.update(plugin_config)
+ if base_config_payload:
+ config_data.update({key: value for key, value in base_config_payload.items() if key in _archivebox_config_input_names()})
+ if crawl_config_base:
+ config_data.update(dict(crawl.config or {}))
config_data.update(archivebox_scope_overrides)
config_data["ABX_RUNTIME"] = "archivebox"
diff --git a/archivebox/config/ldap.py b/archivebox/config/ldap.py
index 40218c5e..16d84ad2 100644
--- a/archivebox/config/ldap.py
+++ b/archivebox/config/ldap.py
@@ -1,6 +1,6 @@
__package__ = "archivebox.config"
-from pydantic import Field
+from pydantic import Field, PrivateAttr
from archivebox.config.configset import BaseConfigSet
@@ -14,6 +14,7 @@ class LDAPConfig(BaseConfigSet):
"""
toml_section_header: str = "LDAP_CONFIG"
+ _scope: str = PrivateAttr(default="server")
LDAP_ENABLED: bool = Field(default=False)
LDAP_SERVER_URI: str | None = Field(default=None)
diff --git a/archivebox/config/views.py b/archivebox/config/views.py
index 9ef6ea50..0fb7bf27 100644
--- a/archivebox/config/views.py
+++ b/archivebox/config/views.py
@@ -109,7 +109,7 @@ def get_machine_admin_url() -> str | None:
from archivebox.machine.models import Machine
machine = Machine.current()
- return getattr(machine, "admin_change_url", None) or f"/admin/machine/machine/{machine.id.hex}/change/"
+ return getattr(machine, "admin_change_url", None) or f"/admin/machine/machine/{machine.id}/change/"
except Exception:
return None
diff --git a/archivebox/core/forms.py b/archivebox/core/forms.py
index c452b3e4..7602d21a 100644
--- a/archivebox/core/forms.py
+++ b/archivebox/core/forms.py
@@ -453,9 +453,9 @@ class PluginConfigFormMixin:
if "array" in _schema_types(prop_schema) and isinstance(prop_schema.get("enum"), list):
raw_value = self.data.getlist(input_name)
- from archivebox.config.common import is_sensitive_config_key
+ from archivebox.config.common import SENSITIVE_CONFIG_VALUE_REDACTED, is_sensitive_config_key
- if (prop_schema.get("x-sensitive") or is_sensitive_config_key(config_key)) and raw_value == "":
+ if (prop_schema.get("x-sensitive") or is_sensitive_config_key(config_key)) and raw_value in ("", SENSITIVE_CONFIG_VALUE_REDACTED):
continue
try:
diff --git a/archivebox/core/models.py b/archivebox/core/models.py
index 2af3a4ed..0905cf37 100755
--- a/archivebox/core/models.py
+++ b/archivebox/core/models.py
@@ -636,12 +636,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
@classmethod
def missing_delete_at_candidates(cls):
- from archivebox.personas.models import Persona
-
- persona_ids = Persona.objects.filter(config__has_key="DELETE_AFTER").values_list("id", flat=True)
- return cls.objects.filter(delete_at__isnull=True).filter(
- Q(config__has_key="DELETE_AFTER") | Q(crawl__config__has_key="DELETE_AFTER") | Q(crawl__persona_id__in=persona_ids),
- )
+ return cls.objects.filter(delete_at__isnull=True).filter(Q(config__has_key="DELETE_AFTER") | Q(crawl__config__has_key="DELETE_AFTER"))
@classmethod
def is_archivebox_internal_url(cls, url: str) -> bool:
@@ -3498,14 +3493,10 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
@classmethod
def missing_delete_at_candidates(cls):
- from archivebox.personas.models import Persona
-
- persona_ids = Persona.objects.filter(config__has_key="DELETE_AFTER").values_list("id", flat=True)
return cls.objects.filter(delete_at__isnull=True).filter(
Q(config__has_key="DELETE_AFTER")
| Q(snapshot__config__has_key="DELETE_AFTER")
- | Q(snapshot__crawl__config__has_key="DELETE_AFTER")
- | Q(snapshot__crawl__persona_id__in=persona_ids),
+ | Q(snapshot__crawl__config__has_key="DELETE_AFTER"),
)
@property
diff --git a/archivebox/core/settings_logging.py b/archivebox/core/settings_logging.py
index 957d2f2a..5613451f 100644
--- a/archivebox/core/settings_logging.py
+++ b/archivebox/core/settings_logging.py
@@ -7,6 +7,7 @@ import logging
from archivebox.config import CONSTANTS
+from archivebox.misc.logging import STDERR
IGNORABLE_URL_PATTERNS = [
@@ -182,6 +183,7 @@ SETTINGS_LOGGING = {
"level": "DEBUG",
"markup": False,
"rich_tracebacks": False, # Use standard Python tracebacks (no frame/box)
+ "console": STDERR,
"filters": ["noisyrequestsfilter", "daphneclosetimeout", "asynciocancelledshield", "stripansi"],
},
"logfile": {
diff --git a/archivebox/core/views.py b/archivebox/core/views.py
index 0bb98752..4b9c26b9 100644
--- a/archivebox/core/views.py
+++ b/archivebox/core/views.py
@@ -33,7 +33,7 @@ from admin_data_views.utils import render_with_table_view, render_with_item_view
from abx_dl.events import PROCESS_EXIT_SKIPPED
from archivebox.config import CONSTANTS, CONSTANTS_CONFIG, VERSION
-from archivebox.config.common import get_config, get_all_configs
+from archivebox.config.common import get_config, get_all_configs, is_sensitive_config_key
from archivebox.config.configset import BaseConfigSet
from archivebox.misc.paginators import CountlessPaginator
from archivebox.misc.util import (
@@ -1177,8 +1177,6 @@ class AddView(UserPassesTestMixin, FormView):
required_search_plugin = f"search_backend_{request_config.SEARCH_BACKEND_ENGINE}".strip()
can_override_crawl_config = self._can_override_crawl_config()
plugin_configs = discover_plugin_configs() if can_override_crawl_config else {}
- from archivebox.config.common import is_sensitive_config_key
-
sensitive_keys = {
str(config_key)
for schema in plugin_configs.values()
@@ -1304,7 +1302,8 @@ class AddView(UserPassesTestMixin, FormView):
config = {}
if plugins:
config["PLUGINS"] = plugins
- effective_config = get_config(persona=persona, user=self.request.user) if persona else get_config(user=self.request.user)
+ request_user = self.request.user if self.request.user.is_authenticated else None
+ effective_config = get_config(persona=persona, user=request_user) if persona else get_config(user=request_user)
if crawl_max_concurrent_snapshots != int(effective_config.CRAWL_MAX_CONCURRENT_SNAPSHOTS):
config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] = crawl_max_concurrent_snapshots
if delete_after != str(effective_config.DELETE_AFTER):
@@ -1352,6 +1351,7 @@ class AddView(UserPassesTestMixin, FormView):
template=crawl,
schedule=schedule,
is_enabled=True,
+ config=config,
label=crawl.label,
notes=f"Auto-created from add page. {notes}".strip(),
created_by_id=created_by_id,
@@ -2508,13 +2508,6 @@ def find_config_type(key: str) -> str:
return "str"
-def key_is_safe(key: str) -> bool:
- for term in ("key", "password", "secret", "token"):
- if term in key.lower():
- return False
- return True
-
-
def find_config_source(key: str, merged_config: dict) -> str:
"""Determine where a config value comes from."""
from archivebox.machine.models import Machine
@@ -2604,7 +2597,7 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
# Use merged config value (includes machine overrides)
actual_value = merged_config.get(key, getattr(section, key, None))
- rows["Value"].append(mark_safe(f"{actual_value}") if key_is_safe(key) else "******** (redacted)")
+ rows["Value"].append(mark_safe(f"{actual_value}") if not is_sensitive_config_key(key) else "******** (redacted)")
# Show where the value comes from
source = find_config_source(key, merged_config)
@@ -2624,7 +2617,7 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
rows["Section"].append(section) # section.replace('_', ' ').title().replace(' Config', '')
rows["Key"].append(ItemLink(key, key=key))
rows["Type"].append(format_html("{}", getattr(type(CONSTANTS_CONFIG[key]), "__name__", str(CONSTANTS_CONFIG[key]))))
- rows["Value"].append(format_html("{}", CONSTANTS_CONFIG[key]) if key_is_safe(key) else "******** (redacted)")
+ rows["Value"].append(format_html("{}", CONSTANTS_CONFIG[key]) if not is_sensitive_config_key(key) else "******** (redacted)")
rows["Source"].append(mark_safe('Constant'))
rows["Default"].append(
mark_safe(
@@ -2655,16 +2648,16 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
# Environment variable
if key in os.environ:
- sources_info.append(("Environment", os.environ[key] if key_is_safe(key) else "********", "blue"))
+ sources_info.append(("Environment", os.environ[key] if not is_sensitive_config_key(key) else "********", "blue"))
# Machine config
machine = None
machine_admin_url = None
try:
machine = Machine.current()
- machine_admin_url = f"/admin/machine/machine/{machine.id.hex}/change/"
+ machine_admin_url = f"/admin/machine/machine/{machine.id}/change/"
if machine.config and key in machine.config:
- sources_info.append(("Machine", machine.config[key] if key_is_safe(key) else "********", "purple"))
+ sources_info.append(("Machine", machine.config[key] if not is_sensitive_config_key(key) else "********", "purple"))
except Exception:
pass
@@ -2680,8 +2673,12 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
sources_info.append(("Default", default_val, "gray"))
# Final computed value
- final_value = merged_config.get(key, CONFIGS.get(key, None))
- if not key_is_safe(key):
+ config_source = find_config_source(key, merged_config)
+ if config_source == "Environment":
+ final_value = get_config(include_machine=False).model_dump(mode="json").get(key, CONFIGS.get(key, None))
+ else:
+ final_value = merged_config.get(key, CONFIGS.get(key, None))
+ if is_sensitive_config_key(key):
final_value = "********"
# Build sources display
@@ -2714,7 +2711,7 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
"Key": key,
"Type": find_config_type(key),
"Value": final_value,
- "Currently read from": find_config_source(key, merged_config),
+ "Currently read from": config_source,
},
"help_texts": {
"Key": mark_safe(f"""
@@ -2731,7 +2728,7 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
"Value": mark_safe(f'''
{
'Value is redacted for your security. (Passwords, secrets, API tokens, etc. cannot be viewed in the Web UI)
'
- if not key_is_safe(key)
+ if is_sensitive_config_key(key)
else ""
}
@@ -2744,12 +2741,12 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
archivebox config --set {key}="{
val.strip("'")
if (val := find_config_default(key))
- else (str(final_value if key_is_safe(key) else "********")).strip("'")
+ else (str(final_value if not is_sensitive_config_key(key) else "********")).strip("'")
}"
'''),
"Currently read from": mark_safe(f"""
- The value shown in the "Value" field comes from the {find_config_source(key, merged_config)} source.
+ The value shown in the "Value" field comes from the {config_source} source.
Priority order (highest to lowest):
diff --git a/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py b/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py
new file mode 100644
index 00000000..b341e18a
--- /dev/null
+++ b/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py
@@ -0,0 +1,33 @@
+from django.db import migrations
+
+
+def freeze_existing_crawl_configs(apps, schema_editor):
+ from archivebox.config.common import build_crawl_config_snapshot
+ from archivebox.personas.models import Persona
+ from django.contrib.auth import get_user_model
+
+ Crawl = apps.get_model("crawls", "Crawl")
+ User = get_user_model()
+ db_alias = schema_editor.connection.alias
+
+ for crawl in Crawl.objects.using(db_alias).select_related("persona", "created_by").iterator(chunk_size=200):
+ current_config = dict(crawl.config or {})
+ persona = Persona.objects.using(db_alias).filter(pk=crawl.persona_id).first()
+ user = User.objects.using(db_alias).filter(pk=crawl.created_by_id).first()
+ frozen_config = build_crawl_config_snapshot(
+ user=user,
+ persona=persona,
+ overrides=current_config,
+ )
+ if frozen_config != current_config:
+ Crawl.objects.using(db_alias).filter(pk=crawl.pk).update(config=frozen_config)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("crawls", "0017_drop_stale_crawl_limit_columns"),
+ ]
+
+ operations = [
+ migrations.RunPython(freeze_existing_crawl_configs, migrations.RunPython.noop),
+ ]
diff --git a/archivebox/crawls/migrations/0019_crawlschedule_config.py b/archivebox/crawls/migrations/0019_crawlschedule_config.py
new file mode 100644
index 00000000..56a5faef
--- /dev/null
+++ b/archivebox/crawls/migrations/0019_crawlschedule_config.py
@@ -0,0 +1,25 @@
+from django.db import migrations, models
+
+
+def copy_template_config_to_schedule(apps, schema_editor):
+ CrawlSchedule = apps.get_model("crawls", "CrawlSchedule")
+ db_alias = schema_editor.connection.alias
+
+ for schedule in CrawlSchedule.objects.using(db_alias).select_related("template").iterator(chunk_size=200):
+ template_config = dict(schedule.template.config or {}) if schedule.template_id else {}
+ CrawlSchedule.objects.using(db_alias).filter(pk=schedule.pk).update(config=template_config)
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("crawls", "0018_freeze_crawl_config_snapshots"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="crawlschedule",
+ name="config",
+ field=models.JSONField(blank=True, default=dict, null=True),
+ ),
+ migrations.RunPython(copy_template_config_to_schedule, migrations.RunPython.noop),
+ ]
diff --git a/archivebox/crawls/models.py b/archivebox/crawls/models.py
index eb072a70..aba6b169 100755
--- a/archivebox/crawls/models.py
+++ b/archivebox/crawls/models.py
@@ -50,6 +50,7 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
template: "Crawl" = models.ForeignKey("Crawl", on_delete=models.CASCADE, null=False, blank=False) # type: ignore
schedule = models.CharField(max_length=64, blank=False, null=False)
is_enabled = models.BooleanField(default=True)
+ config = models.JSONField(default=dict, null=True, blank=True)
label = models.CharField(max_length=64, blank=True, null=False, default="")
notes = models.TextField(blank=True, null=False, default="")
@@ -102,13 +103,17 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
return self.is_enabled and self.next_run_at <= now
def enqueue(self, queued_at=None) -> "Crawl":
+ from archivebox.config.common import build_crawl_config_snapshot
+
queued_at = queued_at or timezone.now()
template = self.template
label = template.label or self.label
+ persona = template.persona if template.persona_id else None
+ user = template.created_by if template.created_by_id else None
return Crawl.objects.create(
urls=template.urls,
- config=template.config or {},
+ config=build_crawl_config_snapshot(user=user, persona=persona, overrides=self.config or {}),
max_depth=template.max_depth,
tags_str=template.tags_str,
persona_id=template.persona_id,
@@ -273,10 +278,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
@classmethod
def missing_delete_at_candidates(cls):
- from archivebox.personas.models import Persona
-
- persona_ids = Persona.objects.filter(config__has_key="DELETE_AFTER").values_list("id", flat=True)
- return cls.objects.filter(delete_at__isnull=True).filter(Q(config__has_key="DELETE_AFTER") | Q(persona_id__in=persona_ids))
+ return cls.objects.filter(delete_at__isnull=True, config__has_key="DELETE_AFTER")
def save(self, *args, **kwargs):
update_fields = kwargs.get("update_fields")
@@ -287,11 +289,16 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
previous_tag_names = set(self.parse_tag_names(old_crawl.tags_str or ""))
config = dict(self.config or {})
+ is_new = self._state.adding or old_crawl is None
+ persona = self.persona if self.persona_id else None
+ user = self.created_by if self.created_by_id else None
+ if is_new:
+ from archivebox.config.common import build_crawl_config_snapshot
+
+ config = build_crawl_config_snapshot(user=user, persona=persona, overrides=config)
if str(config.get("PERMISSIONS") or "").strip().lower() not in PERMISSIONS_VALUES:
from archivebox.config.common import get_config
- persona = self.persona if self.persona_id else None
- user = self.created_by if self.created_by_id else None
config["PERMISSIONS"] = normalize_permissions(get_config(persona=persona, user=user, include_machine=True).PERMISSIONS)
if "CRAWL_MAX_CONCURRENT_SNAPSHOTS" in config:
raw_concurrency = config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"]
diff --git a/archivebox/hooks.py b/archivebox/hooks.py
index a621894b..845b872c 100644
--- a/archivebox/hooks.py
+++ b/archivebox/hooks.py
@@ -324,6 +324,7 @@ def run_hook(
config_scope = {key.removeprefix("config_"): kwargs.pop(key) for key in list(kwargs) if key.startswith("config_")}
resolved_config = get_config(overrides=_config_to_overrides(config), **config_scope)
+ hook_config = resolved_config.for_crawl_execution()
# Auto-detect timeout from plugin config if not explicitly provided
if timeout is None:
@@ -463,7 +464,7 @@ def run_hook(
"SNAP_DIR",
"CRAWL_DIR",
}
- for key, value in resolved_config.items():
+ for key, value in hook_config.items():
if key in SKIP_KEYS:
continue # Already handled specially above, don't overwrite
if value is None:
diff --git a/archivebox/services/process_service.py b/archivebox/services/process_service.py
index 0419b354..f44c459a 100644
--- a/archivebox/services/process_service.py
+++ b/archivebox/services/process_service.py
@@ -146,11 +146,14 @@ class ProcessService(BaseService):
self._ensure_completed_worker()
await self._completed_queue.put(event)
- async def on_CrawlCleanupEvent__flush_completed(self, event: CrawlCleanupEvent) -> None:
+ async def flush_completed(self) -> None:
await self._completed_queue.join()
+ async def on_CrawlCleanupEvent__flush_completed(self, event: CrawlCleanupEvent) -> None:
+ await self.flush_completed()
+
async def on_CrawlCompletedEvent__flush_completed(self, event: CrawlCompletedEvent) -> None:
- await self._completed_queue.join()
+ await self.flush_completed()
async def _save_completed_process_to_db(self, event: ProcessCompletedEvent) -> None:
from archivebox.machine.models import Process
diff --git a/archivebox/services/runner.py b/archivebox/services/runner.py
index c0c6a282..e050d6bc 100644
--- a/archivebox/services/runner.py
+++ b/archivebox/services/runner.py
@@ -58,7 +58,7 @@ from abxbus import BaseEvent
from abxbus.event_bus import EventBus, get_current_event, in_handler_context
from abxbus.event_handler import EventHandlerAbortedError, EventHandlerCancelledError
-from archivebox.config.configset import BaseConfigSet
+from archivebox.config.common import ArchiveBoxBaseConfig
from archivebox.core.recovery_util import recover_orchestrator_state
from archivebox.misc.db import run_db_analyze_batch
from archivebox.core.shutdown_util import foreground_shutdown_signals
@@ -122,16 +122,12 @@ def _count_selected_hooks(plugins: dict[str, Plugin], selected_plugins: list[str
return sum(1 for plugin in selected.values() for hook in plugin.hooks if "CrawlSetup" in hook.name or "Snapshot" in hook.name)
-def _normalize_runtime_config(config: BaseConfigSet | Mapping[str, Any] | str | None) -> dict[str, Any]:
- if config is None:
- return {}
- if isinstance(config, BaseConfigSet):
- config = config.model_dump(mode="json")
- elif isinstance(config, str):
- config = json.loads(config)
- else:
- config = dict(config)
- return {key: value for key, value in json.loads(json.dumps(config, default=str)).items() if value is not None}
+def _normalize_runtime_config(config: ArchiveBoxBaseConfig | Mapping[str, Any] | str | None) -> dict[str, Any]:
+ from archivebox.config.common import normalize_runtime_config
+
+ if isinstance(config, ArchiveBoxBaseConfig):
+ return config.for_crawl_execution()
+ return normalize_runtime_config(config)
def _runner_task_context() -> contextvars.Context:
@@ -1168,7 +1164,7 @@ async def _run_binary(binary_id: str) -> None:
config["ABX_RUNTIME"] = "archivebox"
config = _normalize_runtime_config(config)
bus = create_bus(name=_bus_name("ArchiveBox_binary", str(binary.id)), total_timeout=1800.0)
- PersistedProcessService(bus)
+ process_service = PersistedProcessService(bus)
BinaryService(bus)
TagService(bus)
ArchiveResultService(bus)
@@ -1202,6 +1198,7 @@ async def _run_binary(binary_id: str) -> None:
).now(first_result=True)
finally:
await bus.wait_until_idle()
+ await process_service.flush_completed()
def run_binary(binary_id: str) -> None:
diff --git a/archivebox/tests/conftest.py b/archivebox/tests/conftest.py
index cd0b879a..c047a7a8 100644
--- a/archivebox/tests/conftest.py
+++ b/archivebox/tests/conftest.py
@@ -484,7 +484,7 @@ def build_test_env(port: int, **extra: str) -> dict[str, str]:
"USE_COLOR": "False",
"SHOW_PROGRESS": "False",
"TIMEOUT": "30",
- "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*",
+ "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*|example\.com",
"SAVE_ARCHIVEDOTORG": "False",
"SAVE_TITLE": "False",
"SAVE_FAVICON": "False",
diff --git a/archivebox/tests/fixtures.py b/archivebox/tests/fixtures.py
index 1e634726..a674795f 100644
--- a/archivebox/tests/fixtures.py
+++ b/archivebox/tests/fixtures.py
@@ -35,6 +35,7 @@ def disable_extractors_dict():
"SAVE_ARCHIVEDOTORG": "false",
"SAVE_TITLE": "false",
"SAVE_FAVICON": "false",
+ "PLUGINS": "__archivebox_test_no_plugins__",
},
)
return env
diff --git a/archivebox/tests/test_cli_add.py b/archivebox/tests/test_cli_add.py
index 44b4c4bd..2fa72f88 100644
--- a/archivebox/tests/test_cli_add.py
+++ b/archivebox/tests/test_cli_add.py
@@ -269,7 +269,7 @@ def test_add_records_selected_persona_on_crawl(tmp_path, process, disable_extrac
crawl = Crawl.objects.get()
assert crawl.persona_id
- assert crawl.config.get("DEFAULT_PERSONA") is None
+ assert crawl.config["ACTIVE_PERSONA"] == "Default"
assert (tmp_path / "personas" / "Default" / "chrome_profile").is_dir()
diff --git a/archivebox/tests/test_cli_piping.py b/archivebox/tests/test_cli_piping.py
index d8803e3f..bbbf30b0 100644
--- a/archivebox/tests/test_cli_piping.py
+++ b/archivebox/tests/test_cli_piping.py
@@ -199,6 +199,7 @@ def test_crawl_create_stdout_pipes_into_run(initialized_archive):
create_stdout, create_stderr, create_code = run_archivebox_cmd(
["crawl", "create", url],
data_dir=initialized_archive,
+ env=PIPE_TEST_ENV,
)
assert create_code == 0, create_stderr
_assert_stdout_is_jsonl_only(create_stdout)
@@ -231,6 +232,7 @@ def test_snapshot_list_stdout_pipes_into_run(initialized_archive):
create_stdout, create_stderr, create_code = run_archivebox_cmd(
["snapshot", "create", url],
data_dir=initialized_archive,
+ env=PIPE_TEST_ENV,
)
assert create_code == 0, create_stderr
snapshot = next(record for record in parse_jsonl_output(create_stdout) if record.get("type") == "Snapshot")
@@ -238,11 +240,13 @@ def test_snapshot_list_stdout_pipes_into_run(initialized_archive):
list_stdout, list_stderr, list_code = run_archivebox_cmd(
["snapshot", "list", "--status=queued", f"--url__icontains={snapshot['id']}"],
data_dir=initialized_archive,
+ env=PIPE_TEST_ENV,
)
if list_code != 0 or not parse_jsonl_output(list_stdout):
list_stdout, list_stderr, list_code = run_archivebox_cmd(
["snapshot", "list", f"--url__icontains={url}"],
data_dir=initialized_archive,
+ env=PIPE_TEST_ENV,
)
assert list_code == 0, list_stderr
_assert_stdout_is_jsonl_only(list_stdout)
@@ -272,6 +276,7 @@ def test_archiveresult_list_stdout_pipes_into_run(initialized_archive):
snapshot_stdout, snapshot_stderr, snapshot_code = run_archivebox_cmd(
["snapshot", "create", url],
data_dir=initialized_archive,
+ env=PIPE_TEST_ENV,
)
assert snapshot_code == 0, snapshot_stderr
@@ -279,6 +284,7 @@ def test_archiveresult_list_stdout_pipes_into_run(initialized_archive):
["archiveresult", "create", "--plugin=favicon"],
stdin=snapshot_stdout,
data_dir=initialized_archive,
+ env=PIPE_TEST_ENV,
)
assert ar_create_code == 0, ar_create_stderr
@@ -293,6 +299,7 @@ def test_archiveresult_list_stdout_pipes_into_run(initialized_archive):
list_stdout, list_stderr, list_code = run_archivebox_cmd(
["archiveresult", "list", "--plugin=favicon"],
data_dir=initialized_archive,
+ env=PIPE_TEST_ENV,
)
assert list_code == 0, list_stderr
_assert_stdout_is_jsonl_only(list_stdout)
@@ -348,6 +355,7 @@ def test_multi_stage_pipeline_into_run(initialized_archive):
crawl_stdout, crawl_stderr, crawl_code = run_archivebox_cmd(
["crawl", "create", url],
data_dir=initialized_archive,
+ env=PIPE_TEST_ENV,
)
assert crawl_code == 0, crawl_stderr
_assert_stdout_is_jsonl_only(crawl_stdout)
@@ -356,6 +364,7 @@ def test_multi_stage_pipeline_into_run(initialized_archive):
["snapshot", "create"],
stdin=crawl_stdout,
data_dir=initialized_archive,
+ env=PIPE_TEST_ENV,
)
assert snapshot_code == 0, snapshot_stderr
_assert_stdout_is_jsonl_only(snapshot_stdout)
@@ -364,6 +373,7 @@ def test_multi_stage_pipeline_into_run(initialized_archive):
["archiveresult", "create", "--plugin=favicon"],
stdin=snapshot_stdout,
data_dir=initialized_archive,
+ env=PIPE_TEST_ENV,
)
assert archiveresult_code == 0, archiveresult_stderr
_assert_stdout_is_jsonl_only(archiveresult_stdout)
diff --git a/archivebox/tests/test_frozen_crawl_config.py b/archivebox/tests/test_frozen_crawl_config.py
new file mode 100644
index 00000000..fb188a5e
--- /dev/null
+++ b/archivebox/tests/test_frozen_crawl_config.py
@@ -0,0 +1,200 @@
+import pytest
+from django.test import RequestFactory
+from django.utils import timezone
+
+pytestmark = pytest.mark.django_db(transaction=True)
+
+
+SENSITIVE_SECRET = "raw-twocaptcha-secret-for-frozen-crawl-test"
+UPDATED_SECRET = "updated-secret-that-must-not-affect-old-crawl"
+
+
+@pytest.fixture
+def archivebox_db(initialized_archive):
+ from archivebox.tests.test_orm_helpers import use_archivebox_db
+
+ with use_archivebox_db(initialized_archive):
+ yield initialized_archive
+
+
+def _user(username="frozen-config-admin"):
+ from django.contrib.auth import get_user_model
+
+ return get_user_model().objects.create_superuser(
+ username=username,
+ email=f"{username}@example.com",
+ password="testpassword",
+ )
+
+
+def _persona(user, *, name="Frozen Persona", secret=SENSITIVE_SECRET, user_agent="Frozen UA"):
+ from archivebox.personas.models import Persona
+
+ persona = Persona.objects.create(
+ name=name,
+ created_by=user,
+ config={
+ "PERMISSIONS": "private",
+ "USER_AGENT": user_agent,
+ "TWOCAPTCHA_API_KEY": secret,
+ "DELETE_AFTER": "2h",
+ },
+ )
+ persona.ensure_dirs()
+ return persona
+
+
+def test_crawl_save_freezes_full_raw_persona_config_and_redacts_public_serialization(archivebox_db):
+ from archivebox.config.common import SENSITIVE_CONFIG_VALUE_REDACTED, get_config
+ from archivebox.crawls.models import Crawl
+
+ user = _user()
+ persona = _persona(user)
+
+ crawl = Crawl.objects.create(
+ urls="https://example.com/frozen",
+ persona=persona,
+ created_by=user,
+ config={"CRAWL_MAX_CONCURRENT_SNAPSHOTS": 3},
+ status=Crawl.StatusChoices.QUEUED,
+ retry_at=timezone.now(),
+ )
+
+ assert "TIMEOUT" in crawl.config
+ assert "CHECK_SSL_VALIDITY" in crawl.config
+ assert crawl.config["USER_AGENT"] == "Frozen UA"
+ assert crawl.config["TWOCAPTCHA_API_KEY"] == SENSITIVE_SECRET
+ assert crawl.config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] == 3
+ assert "CRAWL_DIR" not in crawl.config
+ assert "SNAP_DIR" not in crawl.config
+ assert "DEBUG" not in crawl.config
+ assert "SECRET_KEY" not in crawl.config
+ assert "PUBLIC_ADD_VIEW" not in crawl.config
+ assert "DATABASE_NAME" not in crawl.config
+
+ persona.config["USER_AGENT"] = "Mutated UA"
+ persona.config["TWOCAPTCHA_API_KEY"] = UPDATED_SECRET
+ persona.save(update_fields=["config"])
+
+ runtime_config = get_config(crawl=crawl)
+ assert runtime_config.USER_AGENT == "Frozen UA"
+ assert runtime_config.TWOCAPTCHA_API_KEY == SENSITIVE_SECRET
+ execution_config = runtime_config.for_crawl_execution()
+ assert execution_config["DEBUG"] is False
+ assert execution_config["CRAWL_DIR"] == str(crawl.output_dir)
+ assert "SECRET_KEY" not in execution_config
+ assert "PUBLIC_ADD_VIEW" not in execution_config
+ assert "DATABASE_NAME" not in execution_config
+
+ public_json = crawl.to_json()
+ assert public_json["config"]["TWOCAPTCHA_API_KEY"] == SENSITIVE_CONFIG_VALUE_REDACTED
+ assert SENSITIVE_SECRET not in str(public_json)
+
+
+def test_snapshot_config_overlays_frozen_crawl_without_re_reading_persona(archivebox_db):
+ from archivebox.config.common import get_config
+ from archivebox.core.models import Snapshot
+ from archivebox.crawls.models import Crawl
+
+ user = _user("frozen-config-snapshot-admin")
+ persona = _persona(user, name="Frozen Snapshot Persona", user_agent="Crawl UA")
+ crawl = Crawl.objects.create(urls="https://example.com/root", persona=persona, created_by=user, config={"TIMEOUT": 11})
+ snapshot = Snapshot.objects.create(
+ url="https://example.com/root",
+ crawl=crawl,
+ config={"TIMEOUT": 22, "ANTHROPIC_API_KEY": "snapshot-secret"},
+ )
+
+ persona.config["TIMEOUT"] = 99
+ persona.save(update_fields=["config"])
+
+ runtime_config = get_config(crawl=crawl, snapshot=snapshot)
+ assert runtime_config.USER_AGENT == "Crawl UA"
+ assert runtime_config.TIMEOUT == 22
+ assert runtime_config.ANTHROPIC_API_KEY == "snapshot-secret"
+ assert snapshot.config == {"TIMEOUT": 22, "ANTHROPIC_API_KEY": "snapshot-secret"}
+
+
+def test_config_scopes_are_derived_from_section_and_field_metadata():
+ from archivebox.config.common import ArchiveBoxConfig
+
+ assert ArchiveBoxConfig.scope_for_key("TIMEOUT") == "crawl_frozen"
+ assert ArchiveBoxConfig.scope_for_key("DEBUG") == "crawl_execution"
+ assert ArchiveBoxConfig.scope_for_key("CRAWL_DIR") == "crawl_execution"
+ assert ArchiveBoxConfig.scope_for_key("SECRET_KEY") == "server"
+ assert ArchiveBoxConfig.scope_for_key("DATABASE_NAME") == "server"
+
+
+def test_api_create_and_cli_add_store_full_frozen_config(archivebox_db):
+ from archivebox.api.v1_crawls import CrawlCreateSchema, CrawlSchema, create_crawl
+ from archivebox.cli.archivebox_add import add
+ from archivebox.config.common import SENSITIVE_CONFIG_VALUE_REDACTED
+
+ user = _user("frozen-config-api-admin")
+ request = RequestFactory().post("/api/v1/crawls")
+ request.user = user
+
+ api_crawl = create_crawl(
+ request,
+ CrawlCreateSchema(
+ urls=["https://example.com/api"],
+ max_depth=0,
+ tags=[],
+ tags_str="",
+ label="API frozen config",
+ notes="",
+ config={"TWOCAPTCHA_API_KEY": SENSITIVE_SECRET, "TIMEOUT": 33, "SECRET_KEY": "must-not-freeze", "PUBLIC_ADD_VIEW": True},
+ ),
+ )
+ assert "CHECK_SSL_VALIDITY" in api_crawl.config
+ assert api_crawl.config["TIMEOUT"] == 33
+ assert api_crawl.config["TWOCAPTCHA_API_KEY"] == SENSITIVE_SECRET
+ assert "SECRET_KEY" not in api_crawl.config
+ assert "PUBLIC_ADD_VIEW" not in api_crawl.config
+ assert CrawlSchema.resolve_config(api_crawl)["TWOCAPTCHA_API_KEY"] == SENSITIVE_CONFIG_VALUE_REDACTED
+
+ cli_crawl, _snapshots = add(
+ "https://example.com/cli",
+ bg=True,
+ created_by_id=user.pk,
+ config={"TWOCAPTCHA_API_KEY": SENSITIVE_SECRET, "TIMEOUT": 44},
+ )
+ assert "CHECK_SSL_VALIDITY" in cli_crawl.config
+ assert cli_crawl.config["TIMEOUT"] == 44
+ assert cli_crawl.config["TWOCAPTCHA_API_KEY"] == SENSITIVE_SECRET
+
+
+def test_schedule_enqueue_refreezes_using_current_template_persona_defaults(archivebox_db):
+ from archivebox.crawls.models import Crawl, CrawlSchedule
+
+ user = _user("frozen-config-schedule-admin")
+ persona = _persona(user, name="Frozen Schedule Persona", user_agent="Initial schedule UA")
+ template = Crawl.objects.create(
+ urls="https://example.com/scheduled",
+ persona=persona,
+ created_by=user,
+ config={"TIMEOUT": 55, "SECRET_KEY": "template-secret-must-not-freeze", "PUBLIC_ADD_VIEW": True},
+ status=Crawl.StatusChoices.PAUSED,
+ )
+ schedule = CrawlSchedule.objects.create(
+ template=template,
+ schedule="daily",
+ created_by=user,
+ config={"TIMEOUT": 55, "SECRET_KEY": "schedule-secret-must-not-freeze", "PUBLIC_ADD_VIEW": True},
+ )
+
+ assert schedule.config["TIMEOUT"] == 55
+ assert "SECRET_KEY" in schedule.config
+
+ persona.config["USER_AGENT"] = "Current schedule UA"
+ persona.config["TWOCAPTCHA_API_KEY"] = UPDATED_SECRET
+ persona.save(update_fields=["config"])
+
+ child = schedule.enqueue()
+ assert child.config["TIMEOUT"] == 55
+ assert child.config["USER_AGENT"] == "Current schedule UA"
+ assert child.config["TWOCAPTCHA_API_KEY"] == UPDATED_SECRET
+ assert "SECRET_KEY" not in child.config
+ assert "PUBLIC_ADD_VIEW" not in child.config
+ assert template.config["USER_AGENT"] == "Initial schedule UA"
+ assert template.config["TWOCAPTCHA_API_KEY"] == SENSITIVE_SECRET
diff --git a/archivebox/tests/test_ui_add_view.py b/archivebox/tests/test_ui_add_view.py
index 8f99298a..b82e896c 100644
--- a/archivebox/tests/test_ui_add_view.py
+++ b/archivebox/tests/test_ui_add_view.py
@@ -184,7 +184,7 @@ def test_add_view_creates_crawl_with_tag_and_url_filter_overrides(client, admin_
assert crawl.config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] == 5
assert crawl.config["URL_ALLOWLIST"] == "example.com\n*.example.com"
assert crawl.config["URL_DENYLIST"] == "cdn.example.com"
- assert "ONLY_NEW" not in crawl.config
+ assert crawl.config["ONLY_NEW"] is True
def test_add_view_unchecked_only_new_sets_crawl_override(client, admin_user, monkeypatch):
@@ -253,7 +253,7 @@ def test_add_view_selected_persona_wins_over_stale_config_override(client, admin
crawl = Crawl.objects.order_by("-created_at").first()
assert crawl is not None
assert crawl.persona_id == private_persona.id
- assert "DEFAULT_PERSONA" not in crawl.config
+ assert crawl.config["ACTIVE_PERSONA"] == "Private"
assert crawl.resolve_persona() == private_persona
runtime_config = get_config(crawl=crawl)
assert runtime_config.ACTIVE_PERSONA == "Private"
@@ -342,11 +342,11 @@ def test_add_view_public_submission_ignores_plugin_and_custom_config(client, adm
assert crawl.config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] == 2
assert crawl.config["URL_ALLOWLIST"] == "example.com"
assert crawl.config["URL_DENYLIST"] == "cdn.example.com"
- assert "PLUGINS" not in crawl.config
- assert "WGET_TIMEOUT" not in crawl.config
- assert "NODE_BINARY" not in crawl.config
- assert "TWOCAPTCHA_API_KEY" not in crawl.config
- assert "INDEX_ONLY" not in crawl.config
+ assert crawl.config.get("PLUGINS", "") == ""
+ assert crawl.config.get("WGET_TIMEOUT") != 77
+ assert crawl.config.get("NODE_BINARY") != "/tmp/node"
+ assert crawl.config.get("TWOCAPTCHA_API_KEY") != "posted-token"
+ assert crawl.config.get("INDEX_ONLY") is not True
assert crawl.status == Crawl.StatusChoices.QUEUED
assert crawl.schedule is None
@@ -417,7 +417,7 @@ def test_add_view_start_paused_creates_paused_crawl_without_snapshots(client, ad
assert crawl.status == Crawl.StatusChoices.PAUSED
assert crawl.retry_at == RETRY_AT_MAX
assert crawl.snapshot_set.count() == 0
- assert "INDEX_ONLY" not in crawl.config
+ assert crawl.config.get("INDEX_ONLY") is not True
def test_add_view_extracts_urls_from_mixed_text_input(client, admin_user, monkeypatch):
diff --git a/bin/test.sh b/bin/test.sh
index 7567a56c..1f675202 100755
--- a/bin/test.sh
+++ b/bin/test.sh
@@ -14,5 +14,6 @@ DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" >/dev/null 2>&1 && cd .. && pwd )"
source "$DIR/.venv/bin/activate"
-pytest -s --basetemp=archivebox/tests/data "$@"
+mkdir -p "$DIR/tests/out"
+pytest -s --basetemp="$DIR/tests/out" "$@"
exec ./bin/test_plugins.sh