diff --git a/archivebox/__init__.py b/archivebox/__init__.py
index 77534cbc..d8d69109 100755
--- a/archivebox/__init__.py
+++ b/archivebox/__init__.py
@@ -79,16 +79,10 @@ def __getattr__(name: str):
return VERSION
if name in ("BUILTIN_PLUGINS_DIR", "USER_PLUGINS_DIR", "ALL_PLUGINS", "LOADED_PLUGINS"):
from abx_plugins import get_plugins_dir
+ from .config.constants import CONSTANTS
builtin_plugins_dir = Path(get_plugins_dir()).resolve()
- user_plugins_dir = (
- Path(
- os.environ.get("ARCHIVEBOX_USER_PLUGINS_DIR")
- or os.environ.get("USER_PLUGINS_DIR")
- or os.environ.get("DATA_DIR", os.getcwd()),
- )
- / "custom_plugins"
- )
+ user_plugins_dir = CONSTANTS.USER_PLUGINS_DIR
plugins = {
"builtin": builtin_plugins_dir,
"user": user_plugins_dir,
diff --git a/archivebox/api/v1_api.py b/archivebox/api/v1_api.py
index 18cea9f0..a6784e6d 100644
--- a/archivebox/api/v1_api.py
+++ b/archivebox/api/v1_api.py
@@ -68,14 +68,14 @@ class NinjaAPIWithIOCapture(NinjaAPI):
# response['X-ArchiveBox-View'] = self.get_openapi_operation_id(request) or 'Unknown'
# Add Auth Headers to response
- api_token_attr = getattr(request, "_api_token", None)
+ api_token_attr = request.__dict__.get("_api_token")
api_token = api_token_attr if isinstance(api_token_attr, APIToken) else None
token_expiry = api_token.expires.isoformat() if api_token and api_token.expires else "Never"
- response["X-ArchiveBox-Auth-Method"] = str(getattr(request, "_api_auth_method", "None"))
+ response["X-ArchiveBox-Auth-Method"] = str(request.__dict__.get("_api_auth_method", "None"))
response["X-ArchiveBox-Auth-Expires"] = token_expiry
response["X-ArchiveBox-Auth-Token-Id"] = str(api_token.id) if api_token else "None"
- response["X-ArchiveBox-Auth-User-Id"] = str(request.user.pk) if getattr(request.user, "pk", None) else "None"
+ response["X-ArchiveBox-Auth-User-Id"] = str(request.user.pk) if request.user.pk else "None"
response["X-ArchiveBox-Auth-User-Username"] = request.user.username if isinstance(request.user, User) else "None"
# import ipdb; ipdb.set_trace()
diff --git a/archivebox/api/v1_cli.py b/archivebox/api/v1_cli.py
index b1630b74..02ce46e0 100644
--- a/archivebox/api/v1_cli.py
+++ b/archivebox/api/v1_cli.py
@@ -150,8 +150,8 @@ def cli_add(request: HttpRequest, args: AddCommandSchema):
"snapshot_ids": snapshot_ids,
"queued_urls": args.urls,
}
- stdout = getattr(request, "stdout", None)
- stderr = getattr(request, "stderr", None)
+ stdout = request.__dict__.get("stdout")
+ stderr = request.__dict__.get("stderr")
return {
"success": True,
@@ -177,8 +177,8 @@ def cli_update(request: HttpRequest, args: UpdateCommandSchema):
continuous=args.continuous,
stop_daemon_stack=False,
)
- stdout = getattr(request, "stdout", None)
- stderr = getattr(request, "stderr", None)
+ stdout = request.__dict__.get("stdout")
+ stderr = request.__dict__.get("stderr")
return {
"success": True,
"errors": [],
@@ -211,8 +211,8 @@ def cli_schedule(request: HttpRequest, args: ScheduleCommandSchema):
config=config_overrides or None,
)
- stdout = getattr(request, "stdout", None)
- stderr = getattr(request, "stderr", None)
+ stdout = request.__dict__.get("stdout")
+ stderr = request.__dict__.get("stderr")
return {
"success": True,
"errors": [],
@@ -249,8 +249,8 @@ def cli_search(request: HttpRequest, args: ListCommandSchema):
elif args.as_csv:
result_format = "csv"
- stdout = getattr(request, "stdout", None)
- stderr = getattr(request, "stderr", None)
+ stdout = request.__dict__.get("stdout")
+ stderr = request.__dict__.get("stderr")
return {
"success": True,
"errors": [],
@@ -290,8 +290,8 @@ def cli_remove(request: HttpRequest, args: RemoveCommandSchema):
"removed_snapshot_ids": removed_snapshot_ids,
"remaining_snapshots": Snapshot.objects.count(),
}
- stdout = getattr(request, "stdout", None)
- stderr = getattr(request, "stderr", None)
+ stdout = request.__dict__.get("stdout")
+ stderr = request.__dict__.get("stderr")
return {
"success": True,
"errors": [],
diff --git a/archivebox/api/v1_core.py b/archivebox/api/v1_core.py
index 7de3750f..b7f806a0 100644
--- a/archivebox/api/v1_core.py
+++ b/archivebox/api/v1_core.py
@@ -273,7 +273,7 @@ def _parse_archiveresult_output_json(output_json: str | None) -> dict[str, Any]
def _get_archiveresult_upload_data(request: HttpRequest):
- cached = getattr(request, "_archiveresult_upload_data", None)
+ cached = request.__dict__.get("_archiveresult_upload_data")
if cached is not None:
return cached
@@ -468,12 +468,7 @@ def _write_archiveresult_files(
)
guessed_mime = mimetypes.guess_type(relative_output_path)[0]
- output_mime_type = (
- (mime_types[0] if mime_types else "")
- or getattr(uploaded_file, "content_type", None)
- or guessed_mime
- or "application/octet-stream"
- )
+ output_mime_type = (mime_types[0] if mime_types else "") or uploaded_file.content_type or guessed_mime or "application/octet-stream"
output_files[relative_output_path] = {
"extension": PurePosixPath(relative_output_path).suffix.lower().lstrip("."),
"mimetype": output_mime_type,
@@ -501,7 +496,7 @@ def _write_archiveresult_files(
guessed_mime = mimetypes.guess_type(saved_output_path)[0]
output_mime_type = (
(mime_types[index] if index < len(mime_types) else "")
- or getattr(uploaded_file, "content_type", None)
+ or uploaded_file.content_type
or guessed_mime
or "application/octet-stream"
)
@@ -702,7 +697,7 @@ class SnapshotSchema(Schema):
@staticmethod
def resolve_archiveresults(obj, context):
- if bool(getattr(context["request"], "with_archiveresults", False)):
+ if bool(context["request"].__dict__.get("with_archiveresults", False)):
return obj.archiveresult_set.all().distinct()
return ArchiveResult.objects.none()
@@ -883,7 +878,7 @@ def get_snapshots(request: HttpRequest, filters: Query[SnapshotFilterSchema], wi
if not query:
return queryset
- runtime_config = getattr(request, "archivebox_config", None)
+ runtime_config = request.archivebox_config
search_mode = get_search_mode(filters.search_mode, config=runtime_config)
try:
return apply_snapshot_search(
@@ -1081,7 +1076,7 @@ class TagSchema(Schema):
def resolve_created_by_username(obj):
user_model = get_user_model()
user = user_model.objects.get(id=obj.created_by_id)
- username = getattr(user, "username", None)
+ username = user.username
return username if isinstance(username, str) else str(user)
@staticmethod
@@ -1090,7 +1085,7 @@ class TagSchema(Schema):
@staticmethod
def resolve_snapshots(obj, context):
- if bool(getattr(context["request"], "with_snapshots", False)):
+ if bool(context["request"].__dict__.get("with_snapshots", False)):
return obj.snapshot_set.all().distinct()
return Snapshot.objects.none()
@@ -1294,8 +1289,8 @@ def _public_tag_listing_enabled() -> bool:
def _request_has_tag_autocomplete_access(request: HttpRequest) -> bool:
- user = getattr(request, "user", None)
- if getattr(user, "is_authenticated", False):
+ user = request.user
+ if user.is_authenticated:
return True
token = request.GET.get("api_key") or request.headers.get("X-ArchiveBox-API-Key")
@@ -1315,7 +1310,7 @@ def tags_autocomplete(request: HttpRequest, q: str = ""):
if not _request_has_tag_autocomplete_access(request):
raise HttpError(401, "Authentication required")
- public_only = not getattr(request.user, "is_authenticated", False) and not getattr(request, "_api_token", None)
+ public_only = not request.user.is_authenticated and not request.__dict__.get("_api_token")
queryset = get_matching_tags(q)
public_snapshots = public_snapshots_queryset(Snapshot.objects.all())
if public_only:
@@ -1324,7 +1319,7 @@ def tags_autocomplete(request: HttpRequest, q: str = ""):
add_snapshot_counts(tags, snapshot_queryset=public_snapshots if public_only else None)
return {
- "tags": [{"id": tag.pk, "name": tag.name, "num_snapshots": getattr(tag, "num_snapshots", 0)} for tag in tags],
+ "tags": [{"id": tag.pk, "name": tag.name, "num_snapshots": tag.__dict__.get("num_snapshots", 0)} for tag in tags],
}
diff --git a/archivebox/api/v1_crawls.py b/archivebox/api/v1_crawls.py
index e0f602b9..ef043ede 100644
--- a/archivebox/api/v1_crawls.py
+++ b/archivebox/api/v1_crawls.py
@@ -58,7 +58,7 @@ class CrawlSchema(Schema):
def resolve_created_by_username(obj):
user_model = get_user_model()
user = user_model.objects.get(id=obj.created_by_id)
- username = getattr(user, "username", None)
+ username = user.username
return username if isinstance(username, str) else str(user)
@staticmethod
@@ -71,7 +71,7 @@ class CrawlSchema(Schema):
@staticmethod
def resolve_snapshots(obj, context):
- if bool(getattr(context["request"], "with_snapshots", False)):
+ if bool(context["request"].__dict__.get("with_snapshots", False)):
return obj.snapshot_set.all().distinct()
return Snapshot.objects.none()
@@ -122,8 +122,7 @@ def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
tags = normalize_tag_list(data.tags, data.tags_str)
config = dict(data.config or {})
- request_user = request.user if request.user.is_authenticated else None
- config.setdefault("PERMISSIONS", str(get_config(user=request_user).PERMISSIONS))
+ config.setdefault("PERMISSIONS", str(get_config().PERMISSIONS))
crawl = Crawl.objects.create(
urls="\n".join(urls),
max_depth=data.max_depth,
@@ -164,8 +163,8 @@ def crawl_file(request: HttpRequest, crawl_id: str, path: str):
# Determine the effective viewer: session user takes precedence, otherwise
# fall back to an API token passed via ?api_key=, X-ArchiveBox-API-Key, or
# Authorization: Bearer ... (so that programmatic clients still work).
- user = getattr(request, "user", None)
- is_authenticated = bool(getattr(user, "is_authenticated", False) and getattr(user, "is_active", False))
+ user = request.user
+ is_authenticated = bool(user.is_authenticated and user.is_active)
if not is_authenticated:
token = request.GET.get("api_key") or request.headers.get("X-ArchiveBox-API-Key")
auth_header = request.headers.get("Authorization", "")
@@ -183,7 +182,7 @@ def crawl_file(request: HttpRequest, crawl_id: str, path: str):
# must be PUBLIC or UNLISTED. Don't disclose existence of private crawls.
if not is_admin_user(request):
permissions = normalize_permissions(crawl.permissions)
- is_owner = bool(is_authenticated and getattr(crawl, "created_by_id", None) == getattr(user, "id", None))
+ is_owner = bool(is_authenticated and crawl.created_by_id == user.id)
if not is_owner and permissions not in {PERMISSIONS_PUBLIC, PERMISSIONS_UNLISTED}:
raise HttpError(404, "Crawl not found")
diff --git a/archivebox/api/v1_personas.py b/archivebox/api/v1_personas.py
index 24e98117..65945762 100644
--- a/archivebox/api/v1_personas.py
+++ b/archivebox/api/v1_personas.py
@@ -136,7 +136,7 @@ def sync_persona(request: HttpRequest, payload: PersonaSyncSchema):
created = persona is None
if persona is None:
persona = Persona(name=name)
- if getattr(request.user, "is_authenticated", False):
+ if request.user.is_authenticated:
persona.created_by = request.user
persona.config = {
diff --git a/archivebox/base_models/admin.py b/archivebox/base_models/admin.py
index f882666b..887dae39 100644
--- a/archivebox/base_models/admin.py
+++ b/archivebox/base_models/admin.py
@@ -5,7 +5,7 @@ __package__ = "archivebox.base_models"
import json
import uuid
from collections.abc import Mapping
-from typing import NotRequired, TypedDict
+from typing import NotRequired, TypedDict, cast
from django import forms
from django.contrib import admin
@@ -76,39 +76,23 @@ class KeyValueWidget(forms.Widget):
def _get_config_options(self) -> dict[str, ConfigOption]:
"""Get available config options from plugins."""
try:
- from archivebox.config.common import ArchiveBoxConfig
- from archivebox.plugins.discovery import discover_plugin_configs
+ from archivebox.config.common import config_field_metadata
options: dict[str, ConfigOption] = {}
- skipped_core_keys = {"ABX_RUNTIME", "DATA_DIR", "CRAWL_DIR", "SNAP_DIR"}
- for key, field in ArchiveBoxConfig.model_fields.items():
- if key in skipped_core_keys or key in ArchiveBoxConfig.computed_config_keys:
- continue
- default = field.default
- try:
- json.dumps(default)
- except TypeError:
- default = str(default)
- options[key] = {
- "plugin": "archivebox",
- "type": str(field.annotation),
- "default": default,
- "description": field.description or "",
+ for key, metadata in config_field_metadata().items():
+ option_type = metadata.get("type", "string")
+ option: ConfigOption = {
+ "plugin": str(metadata.get("plugin", "archivebox")),
+ "type": cast(str | list[str], option_type if isinstance(option_type, (str, list)) else str(option_type)),
+ "default": metadata.get("default", ""),
+ "description": str(metadata.get("description", "")),
}
-
- plugin_configs = discover_plugin_configs()
- for plugin_name, schema in plugin_configs.items():
- for key, prop in schema.get("properties", {}).items():
- option: ConfigOption = {
- "plugin": plugin_name,
- "type": prop.get("type", "string"),
- "default": prop.get("default", ""),
- "description": prop.get("description", ""),
- }
+ schema = metadata.get("schema")
+ if isinstance(schema, Mapping):
for schema_key in ("enum", "pattern", "minimum", "maximum"):
- if schema_key in prop:
- option[schema_key] = prop[schema_key]
- options[key] = option
+ if schema_key in schema:
+ option[schema_key] = schema[schema_key]
+ options[key] = option
return options
except Exception:
return {}
@@ -839,7 +823,7 @@ class ConfigEditorMixin(admin.ModelAdmin):
"""
from archivebox.config.common import is_sensitive_config_key
- if change and obj.pk and getattr(obj, "config", None) is not None:
+ if change and obj.pk and obj.config is not None:
try:
stored = type(obj).objects.filter(pk=obj.pk).values_list("config", flat=True).first() or {}
except Exception:
diff --git a/archivebox/base_models/models.py b/archivebox/base_models/models.py
index f44bbbbf..20b02bce 100755
--- a/archivebox/base_models/models.py
+++ b/archivebox/base_models/models.py
@@ -20,6 +20,8 @@ from django.conf import settings
from django_stubs_ext.db.models import TypedModelMeta
+from archivebox.config import CONSTANTS
+
def normalize_config_json_values(config: Any) -> Any:
if not isinstance(config, dict):
@@ -165,7 +167,7 @@ class ModelWithDeleteAfter(models.Model):
def get_delete_after_config_value(self):
from archivebox.config.common import get_config
- return get_config(include_machine=False).DELETE_AFTER
+ return get_config(include_machine=False, resolve_plugins=False).DELETE_AFTER
def set_delete_at_from_config(self, config_value=None) -> bool:
if self.delete_at is not None:
@@ -253,9 +255,7 @@ class ModelWithOutputDir(ModelWithUUID):
@classmethod
def validate_output_paths_for_delete(cls, paths) -> tuple[Path, ...]:
- from archivebox.config.common import get_config
-
- data_dir = get_config().DATA_DIR.resolve()
+ data_dir = CONSTANTS.DATA_DIR.resolve()
safe_paths = []
for raw_path in paths:
path = Path(raw_path)
diff --git a/archivebox/cli/__init__.py b/archivebox/cli/__init__.py
index dfba0284..bbb29ca5 100644
--- a/archivebox/cli/__init__.py
+++ b/archivebox/cli/__init__.py
@@ -139,9 +139,9 @@ class ArchiveBoxGroup(click.Group):
# print(f'LAZY LOADING {import_path}')
mod = import_module(modname)
- func = getattr(mod, funcname)
+ func = vars(mod)[funcname]
- if not hasattr(func, "__doc__"):
+ if func.__doc__ is None:
raise ValueError(f"lazy loading of {import_path} failed - no docstring found on method")
# if not isinstance(cmd, click.BaseCommand):
diff --git a/archivebox/cli/archivebox_add.py b/archivebox/cli/archivebox_add.py
index 7b612fa7..3108fb86 100644
--- a/archivebox/cli/archivebox_add.py
+++ b/archivebox/cli/archivebox_add.py
@@ -113,11 +113,9 @@ def add(
from archivebox.misc.logging_util import printable_filesize
from archivebox.misc.system import get_dir_size
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
- from django.contrib.auth import get_user_model
from django.utils import timezone
created_by_id = created_by_id or get_or_create_system_user_pk()
- created_by = get_user_model().objects.filter(pk=created_by_id).first()
started_at = timezone.now()
if isinstance(urls, str):
@@ -151,7 +149,7 @@ def add(
plugins = plugins or ""
persona_obj, _ = Persona.objects.get_or_create(name=persona_name)
persona_obj.ensure_dirs()
- effective_persona_config = get_config(persona=persona_obj, user=created_by)
+ effective_persona_config = get_config(persona=persona_obj)
crawl_config = {
"PERMISSIONS": str(effective_persona_config.PERMISSIONS),
@@ -172,9 +170,8 @@ def add(
**({"URL_DENYLIST": url_denylist} if url_denylist else {}),
}
# Caller-supplied overrides (e.g. {"ONLY_NEW": False}) are the highest
- # priority — they win over persona/plugin/env defaults and get stamped
- # directly onto crawl.config so the runtime resolution and admin UI both
- # reflect them faithfully.
+ # priority for crawl-frozen keys. Runtime-derived execution keys are
+ # stripped by Crawl.save() and rederived when hooks run.
crawl_config.update(config_overrides)
crawl = Crawl.objects.create(
diff --git a/archivebox/cli/archivebox_config.py b/archivebox/cli/archivebox_config.py
index f8e76ca0..86b8632f 100644
--- a/archivebox/cli/archivebox_config.py
+++ b/archivebox/cli/archivebox_config.py
@@ -58,7 +58,7 @@ def config(
]
matching_config = {key: FLAT_CONFIG[key] for key in config_options if key in FLAT_CONFIG}
for config_section in CONFIGS.values():
- aliases = getattr(config_section, "aliases", {})
+ aliases = {str(field.alias): field_name for field_name, field in type(config_section).model_fields.items() if field.alias}
for search_key in config_options:
# search all aliases in the section
@@ -90,7 +90,7 @@ def config(
# Display core config sections
for config_section in CONFIGS.values():
- section_header = getattr(config_section, "toml_section_header", "")
+ section_header = config_section.toml_section_header
if isinstance(section_header, str) and section_header:
print(f"[grey53]\\[{section_header}][/grey53]")
else:
diff --git a/archivebox/cli/archivebox_help.py b/archivebox/cli/archivebox_help.py
index 49be62a4..812b78d4 100755
--- a/archivebox/cli/archivebox_help.py
+++ b/archivebox/cli/archivebox_help.py
@@ -85,8 +85,8 @@ def help() -> None:
[link=https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration]https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration[/link]
""")
- config = get_config()
- if os.access(config.ARCHIVE_DIR, os.R_OK) and config.ARCHIVE_DIR.is_dir():
+ get_config()
+ if os.access(CONSTANTS.ARCHIVE_DIR, os.R_OK) and CONSTANTS.ARCHIVE_DIR.is_dir():
pretty_out_dir = str(CONSTANTS.DATA_DIR).replace(str(Path("~").expanduser()), "~")
EXAMPLE_USAGE = f"""
[light_slate_blue]DATA DIR[/light_slate_blue]: [yellow]{pretty_out_dir}[/yellow]
diff --git a/archivebox/cli/archivebox_init.py b/archivebox/cli/archivebox_init.py
index 87bc05f2..0cc946d3 100755
--- a/archivebox/cli/archivebox_init.py
+++ b/archivebox/cli/archivebox_init.py
@@ -25,7 +25,7 @@ def _display_data_path(path: Path, data_dir: Path) -> str:
def init(force: bool = False, quick: bool = False, install: bool = False) -> None:
"""Initialize a new ArchiveBox collection in the current directory"""
- from archivebox.config import CONSTANTS, VERSION, DATA_DIR
+ from archivebox.config import CONSTANTS, VERSION
from archivebox.config.common import get_config
from archivebox.config.collection import write_config_file
from archivebox.misc.db import apply_migrations
@@ -37,7 +37,7 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
# print("[red]:warning: This folder contains a JSON index. It is deprecated, and will no longer be kept up to date automatically.[/red]", file=sys.stderr)
# print("[red] You can run `archivebox list --json --with-headers > static_index.json` to manually generate it.[/red]", file=sys.stderr)
- is_empty = not len(set(os.listdir(DATA_DIR)) - CONSTANTS.ALLOWED_IN_DATA_DIR)
+ is_empty = not len(set(os.listdir(CONSTANTS.DATA_DIR)) - CONSTANTS.ALLOWED_IN_DATA_DIR)
existing_index = os.path.isfile(CONSTANTS.DATABASE_FILE)
if is_empty and not existing_index:
print(f"[turquoise4][+] Initializing a new ArchiveBox v{VERSION} collection...[/turquoise4]")
@@ -65,23 +65,23 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
else:
print("\n[green][+] Building archive folder structure...[/green]")
- archive_path = _display_data_path(config.ARCHIVE_DIR, DATA_DIR)
- sources_path = _display_data_path(CONSTANTS.SOURCES_DIR, DATA_DIR)
- logs_path = _display_data_path(CONSTANTS.LOGS_DIR, DATA_DIR)
+ archive_path = _display_data_path(CONSTANTS.ARCHIVE_DIR, CONSTANTS.DATA_DIR)
+ sources_path = _display_data_path(CONSTANTS.SOURCES_DIR, CONSTANTS.DATA_DIR)
+ logs_path = _display_data_path(CONSTANTS.LOGS_DIR, CONSTANTS.DATA_DIR)
print(f" + {archive_path}, {sources_path}, {logs_path}...")
Path(CONSTANTS.SOURCES_DIR).mkdir(exist_ok=True)
- config.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
- config.USERS_DIR.mkdir(parents=True, exist_ok=True)
+ CONSTANTS.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
+ CONSTANTS.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)):
+ for path in (Path(CONSTANTS.SOURCES_DIR), CONSTANTS.ARCHIVE_DIR, CONSTANTS.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)}...")
+ print(f" + {_display_data_path(CONSTANTS.CONFIG_FILE, CONSTANTS.DATA_DIR)}...")
# create the .archivebox_id file with a unique ID for this collection
from archivebox.config.paths import _get_collection_id
- _get_collection_id(DATA_DIR, force_create=True)
+ _get_collection_id(CONSTANTS.DATA_DIR, force_create=True)
# create the ArchiveBox.conf file
write_config_file({"SECRET_KEY": config.SECRET_KEY})
@@ -96,12 +96,12 @@ def init(force: bool = False, quick: bool = False, install: bool = False) -> Non
setup_django()
check_migrations(blocking=True, auto_apply=False)
- for migration_line in apply_migrations(DATA_DIR):
+ for migration_line in apply_migrations(CONSTANTS.DATA_DIR):
sys.stdout.write(f" {migration_line}\n")
assert os.path.isfile(CONSTANTS.DATABASE_FILE) and os.access(CONSTANTS.DATABASE_FILE, os.R_OK)
print()
- print(f" √ {_display_data_path(CONSTANTS.DATABASE_FILE, DATA_DIR)}")
+ print(f" √ {_display_data_path(CONSTANTS.DATABASE_FILE, CONSTANTS.DATA_DIR)}")
# from django.contrib.auth.models import User
# call_command("createsuperuser", interactive=True)
diff --git a/archivebox/cli/archivebox_install.py b/archivebox/cli/archivebox_install.py
index 6817e345..b0d592f2 100755
--- a/archivebox/cli/archivebox_install.py
+++ b/archivebox/cli/archivebox_install.py
@@ -22,12 +22,11 @@ def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bo
"""
from archivebox.config.permissions import IS_ROOT, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP
- from archivebox.config.common import get_config
+ from archivebox.config import CONSTANTS
from archivebox.misc.logging import stderr
from archivebox.cli.archivebox_init import init
- config = get_config()
- archive_dir = config.ARCHIVE_DIR
+ archive_dir = CONSTANTS.ARCHIVE_DIR
if dry_run:
print("[dim]Dry run - would detect ArchiveBox dependencies and run the abx-dl install flow[/dim]")
diff --git a/archivebox/cli/archivebox_persona.py b/archivebox/cli/archivebox_persona.py
index 11b9d298..8d82759f 100644
--- a/archivebox/cli/archivebox_persona.py
+++ b/archivebox/cli/archivebox_persona.py
@@ -372,7 +372,7 @@ def create_personas(
if not is_tty:
write_record(
{
- "id": str(persona.id) if hasattr(persona, "id") else None,
+ "id": str(persona.id),
"name": persona.name,
"path": str(persona.path),
"CHROME_USER_DATA_DIR": persona.CHROME_USER_DATA_DIR,
@@ -424,7 +424,7 @@ def list_personas(
else:
write_record(
{
- "id": str(persona.id) if hasattr(persona, "id") else None,
+ "id": str(persona.id),
"name": persona.name,
"path": str(persona.path),
"CHROME_USER_DATA_DIR": persona.CHROME_USER_DATA_DIR,
@@ -500,7 +500,7 @@ def update_personas(name: str | None = None) -> int:
if not is_tty:
write_record(
{
- "id": str(persona.id) if hasattr(persona, "id") else None,
+ "id": str(persona.id),
"name": persona.name,
"path": str(persona.path),
},
diff --git a/archivebox/cli/archivebox_remove.py b/archivebox/cli/archivebox_remove.py
index eb813325..d677e3c6 100644
--- a/archivebox/cli/archivebox_remove.py
+++ b/archivebox/cli/archivebox_remove.py
@@ -12,7 +12,7 @@ import rich_click as click
from django.db import OperationalError
from django.db.models import QuerySet
-from archivebox.config import DATA_DIR
+from archivebox.config import CONSTANTS
from archivebox.config.django import setup_django
from archivebox.misc.util import enforce_types, docstring
from archivebox.misc.checks import check_data_folder
@@ -33,7 +33,7 @@ def remove(
after: float | None = None,
before: float | None = None,
yes: bool = False,
- out_dir: Path = DATA_DIR,
+ out_dir: Path = CONSTANTS.DATA_DIR,
) -> QuerySet:
"""Remove the specified URLs from the archive"""
diff --git a/archivebox/cli/archivebox_run.py b/archivebox/cli/archivebox_run.py
index b8894e30..709d580f 100644
--- a/archivebox/cli/archivebox_run.py
+++ b/archivebox/cli/archivebox_run.py
@@ -40,14 +40,23 @@ Examples:
__package__ = "archivebox.cli"
__command__ = "archivebox run"
-import sys
import asyncio
+import os
+import signal
+import sys
from collections import defaultdict
import rich_click as click
from rich import print as rprint
+RUNNER_DAEMON_ENV = "ARCHIVEBOX_RUNNER_DAEMON"
+
+
+def _exit_daemon_runner_on_signal(sig: signal.Signals) -> None:
+ os._exit(128 + int(sig))
+
+
def process_stdin_records() -> int:
"""
Process JSONL records from stdin.
@@ -281,8 +290,16 @@ def run_runner(daemon: bool = False, crawl_id: str | None = None, maintenance_on
# hook, continue/retry, second Ctrl+C exits" flow. Server/update/run owned
# orchestrators should shut down immediately and cleanly on the first signal.
interactive_interrupts = current.root.process_type == Process.TypeChoices.ADD
+ if daemon:
+ os.environ[RUNNER_DAEMON_ENV] = "1"
try:
- with foreground_shutdown_signals(), foreground_parent_watchdog(enabled=not daemon):
+ with (
+ foreground_shutdown_signals(
+ on_signal=_exit_daemon_runner_on_signal if daemon else None,
+ raise_on_first_signal=not daemon,
+ ),
+ foreground_parent_watchdog(enabled=not daemon),
+ ):
run_pending_crawls(
daemon=daemon,
crawl_id=crawl_id,
@@ -322,7 +339,14 @@ def main(daemon: bool, crawl_id: str, snapshot_id: str, binary_id: str, maintena
if daemon and not snapshot_id and not binary_id and not crawl_id:
try:
- with foreground_shutdown_signals(), foreground_parent_watchdog(enabled=False):
+ os.environ[RUNNER_DAEMON_ENV] = "1"
+ with (
+ foreground_shutdown_signals(
+ on_signal=_exit_daemon_runner_on_signal,
+ raise_on_first_signal=False,
+ ),
+ foreground_parent_watchdog(enabled=False),
+ ):
sys.exit(run_runner(daemon=True, maintenance_only=maintenance_only))
except KeyboardInterrupt:
sys.exit(0)
diff --git a/archivebox/cli/archivebox_search.py b/archivebox/cli/archivebox_search.py
index 5047b2f3..e84f3698 100644
--- a/archivebox/cli/archivebox_search.py
+++ b/archivebox/cli/archivebox_search.py
@@ -12,7 +12,7 @@ import rich_click as click
from django.db.models import Q, QuerySet
-from archivebox.config import DATA_DIR
+from archivebox.config import CONSTANTS
from archivebox.config.common import get_config
from archivebox.misc.logging import stderr
from archivebox.misc.util import enforce_types, docstring
@@ -146,7 +146,7 @@ def get_snapshots(
filter_type: str = "substring",
after: float | None = None,
before: float | None = None,
- out_dir: Path = DATA_DIR,
+ out_dir: Path = CONSTANTS.DATA_DIR,
) -> QuerySet["Snapshot", "Snapshot"]:
"""Filter and return Snapshots matching the given criteria."""
from archivebox.core.models import Snapshot
diff --git a/archivebox/cli/archivebox_server.py b/archivebox/cli/archivebox_server.py
index 09f2d88f..e6934558 100644
--- a/archivebox/cli/archivebox_server.py
+++ b/archivebox/cli/archivebox_server.py
@@ -12,6 +12,7 @@ from collections.abc import Iterable
import rich_click as click
from rich import print
+from archivebox.config import CONSTANTS
from archivebox.misc.util import docstring, enforce_types
@@ -259,8 +260,6 @@ def server(
host, port = _parse_and_validate_bind_spec(bind_spec)
if daemonize and os.environ.get("ARCHIVEBOX_SERVER_DAEMON_CHILD") != "1":
- from archivebox.config import CONSTANTS
-
log_path = CONSTANTS.LOGS_DIR / "server.log"
log_path.parent.mkdir(parents=True, exist_ok=True)
daemon_env = os.environ.copy()
@@ -337,21 +336,25 @@ def server(
runtime_config = get_config()
_print_server_startup_warnings(runtime_config, host, port)
bind_url = f"http://{host}:{port}"
- command = current_command(Process.TypeChoices.SERVER, data_dir=config.DATA_DIR, url=bind_url)
+ command = current_command(Process.TypeChoices.SERVER, data_dir=CONSTANTS.DATA_DIR, url=bind_url)
def still_owns_runtime_stack() -> bool:
from django.db import connections
try:
- return command_owns_runtime_stack(command, data_dir=config.DATA_DIR)
+ return command_owns_runtime_stack(command, data_dir=CONSTANTS.DATA_DIR)
finally:
connections.close_all()
+ shutdown_state = None
try:
- with foreground_shutdown_signals(), foreground_parent_watchdog(enabled=os.environ.get("ARCHIVEBOX_SERVER_DAEMON_CHILD") != "1"):
+ with (
+ foreground_shutdown_signals() as shutdown_state,
+ foreground_parent_watchdog(enabled=os.environ.get("ARCHIVEBOX_SERVER_DAEMON_CHILD") != "1"),
+ ):
while True:
- standby_result = standby_until_runtime_stack_needed(command, data_dir=config.DATA_DIR)
- older_owner = runtime_stack_owner(data_dir=config.DATA_DIR, exclude_id=command.id)
+ standby_result = standby_until_runtime_stack_needed(command, data_dir=CONSTANTS.DATA_DIR)
+ older_owner = runtime_stack_owner(data_dir=CONSTANTS.DATA_DIR, exclude_id=command.id)
takeover_components = active_supervisord_runtime_components(config=config)
if older_owner and takeover_components:
print(
@@ -386,7 +389,8 @@ def server(
except KeyboardInterrupt:
pass
finally:
- command.mark_exited()
+ if not shutdown_state or not shutdown_state.signal_name:
+ command.mark_exited()
print("\n[i][green][🟩] ArchiveBox server shut down gracefully.[/green][/i]")
diff --git a/archivebox/cli/archivebox_snapshot.py b/archivebox/cli/archivebox_snapshot.py
index 5ec3acd2..4d3a6388 100644
--- a/archivebox/cli/archivebox_snapshot.py
+++ b/archivebox/cli/archivebox_snapshot.py
@@ -313,13 +313,7 @@ def list_snapshots(
for snapshot in queryset.iterator(chunk_size=500):
if set(cols).issubset(simple_cols):
rows.append(
- ",".join(
- to_json(
- value.isoformat() if hasattr((value := getattr(snapshot, col, "")), "isoformat") else value,
- indent=None,
- )
- for col in cols
- ),
+ ",".join(to_json(snapshot.serializable_value(col), indent=None) for col in cols),
)
else:
rows.append(snapshot.to_csv(cols=cols, separator=","))
diff --git a/archivebox/cli/archivebox_status.py b/archivebox/cli/archivebox_status.py
index 2aad7b1e..8431fbef 100644
--- a/archivebox/cli/archivebox_status.py
+++ b/archivebox/cli/archivebox_status.py
@@ -8,7 +8,7 @@ import rich_click as click
from rich import print
from archivebox.misc.util import enforce_types, docstring
-from archivebox.config import DATA_DIR, CONSTANTS
+from archivebox.config import CONSTANTS
from archivebox.config.common import get_config
from archivebox.misc.system import get_dir_size
from archivebox.misc.logging_util import printable_filesize
@@ -18,7 +18,7 @@ MAX_STATUS_FS_DIR_SCAN = 5000
@enforce_types
-def status(out_dir: Path = DATA_DIR) -> None:
+def status(out_dir: Path = CONSTANTS.DATA_DIR) -> None:
"""Print out some info and statistics about the archive collection"""
from django.contrib.auth import get_user_model
@@ -38,7 +38,7 @@ def status(out_dir: Path = DATA_DIR) -> None:
snapshots_qs = Snapshot.objects.all()
num_sql_links = snapshots_qs.count()
- archive_dir = config.ARCHIVE_DIR
+ archive_dir = CONSTANTS.ARCHIVE_DIR
legacy_snapshot_dirs = []
if archive_dir.exists():
legacy_snapshot_dirs = [
@@ -48,7 +48,7 @@ def status(out_dir: Path = DATA_DIR) -> None:
print(f" > JSON Link Details: {len(legacy_snapshot_dirs)} links".ljust(36), f"(found in {archive_dir.name}/*/index.json)")
print()
print("[green]\\[*] Scanning archive data directories...[/green]")
- users_dir = config.USERS_DIR
+ users_dir = CONSTANTS.USERS_DIR
scan_roots = [root for root in (archive_dir, users_dir) if root.exists()]
scan_roots_display = ", ".join(str(root) for root in scan_roots) if scan_roots else str(archive_dir)
print(f"[yellow] {scan_roots_display}[/yellow]")
diff --git a/archivebox/cli/archivebox_update.py b/archivebox/cli/archivebox_update.py
index 83a151d2..83741116 100644
--- a/archivebox/cli/archivebox_update.py
+++ b/archivebox/cli/archivebox_update.py
@@ -401,7 +401,7 @@ def update(
resume = None
except (KeyboardInterrupt, asyncio.CancelledError) as err:
exit_code = 130
- exact_resume = getattr(err, "archivebox_resume", None)
+ exact_resume = err.__dict__.get("archivebox_resume")
resume_cmd = ["archivebox", "update"]
if migrate_only:
resume_cmd.append("--migrate-only")
@@ -464,7 +464,7 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
1:1 mapping between DB and filesystem.
"""
from archivebox.core.models import Snapshot
- from archivebox.config.common import get_config
+ from archivebox.config import CONSTANTS
from archivebox.crawls.models import Crawl
from django.utils import timezone
@@ -473,8 +473,7 @@ def drain_old_archive_dirs(resume_from: str | None = None, batch_size: int = 500
crawl_url_sets: dict[str, set[str]] = {}
dirty_crawl_ids: set[str] = set()
- runtime_config = get_config()
- archive_dir = runtime_config.ARCHIVE_DIR
+ archive_dir = CONSTANTS.ARCHIVE_DIR
if not archive_dir.exists():
return stats
diff --git a/archivebox/cli/archivebox_version.py b/archivebox/cli/archivebox_version.py
index 0635dc06..eed12952 100755
--- a/archivebox/cli/archivebox_version.py
+++ b/archivebox/cli/archivebox_version.py
@@ -32,7 +32,6 @@ def _format_binary_abspath(
(lib_dir, "LIB_DIR/"),
(Path(os.environ.get("LIB_DIR", "")), "LIB_DIR/") if os.environ.get("LIB_DIR") else (Path(), ""),
(personas_dir, "PERSONAS_DIR/"),
- (Path(os.environ.get("PERSONAS_DIR", "")), "PERSONAS_DIR/") if os.environ.get("PERSONAS_DIR") else (Path(), ""),
(home, "~/"),
)
@@ -155,7 +154,7 @@ def version(
)
prnt()
- if not (os.access(config.ARCHIVE_DIR, os.R_OK) and os.access(CONSTANTS.CONFIG_FILE, os.R_OK)):
+ if not (os.access(CONSTANTS.ARCHIVE_DIR, os.R_OK) and os.access(CONSTANTS.CONFIG_FILE, os.R_OK)):
PANEL_TEXT = "\n".join(
(
"",
@@ -219,7 +218,7 @@ def version(
installed.abspath,
pwd=Path.cwd(),
lib_dir=config.LIB_DIR,
- personas_dir=config.PERSONAS_DIR,
+ personas_dir=CONSTANTS.PERSONAS_DIR,
home=Path.home(),
)
if compact_paths
@@ -275,7 +274,7 @@ def version(
prnt(f" [red]Error getting code locations: {e}[/red]")
prnt()
- if os.access(config.ARCHIVE_DIR, os.R_OK) or os.access(CONSTANTS.CONFIG_FILE, os.R_OK):
+ if os.access(CONSTANTS.ARCHIVE_DIR, os.R_OK) or os.access(CONSTANTS.CONFIG_FILE, os.R_OK):
prnt("[bright_yellow][i] Data locations:[/bright_yellow]")
try:
for name, path in get_data_locations().items():
diff --git a/archivebox/config/collection.py b/archivebox/config/collection.py
index 1a83777c..549b139a 100644
--- a/archivebox/config/collection.py
+++ b/archivebox/config/collection.py
@@ -3,6 +3,7 @@ __package__ = "archivebox.config"
import io
import json
import os
+from collections.abc import Mapping
from typing import Any
from archivebox.config.constants import CONSTANTS
@@ -54,7 +55,7 @@ def _coerce_to_str_dict(config: Any) -> dict[str, str]:
"""
if not config:
return {}
- if not hasattr(config, "items"):
+ if not isinstance(config, Mapping):
return {}
flat: dict[str, str] = {}
for key, value in config.items():
diff --git a/archivebox/config/common.py b/archivebox/config/common.py
index 88d30207..e345494a 100644
--- a/archivebox/config/common.py
+++ b/archivebox/config/common.py
@@ -8,11 +8,13 @@ import re
import secrets
import sys
import shutil
+import inspect
from functools import lru_cache
from collections.abc import Mapping
from datetime import timedelta
from typing import Any, ClassVar, cast
from pathlib import Path
+from urllib.parse import quote
from rich.console import Console
from pydantic import BaseModel, Field, PrivateAttr, create_model, field_validator, model_validator
@@ -30,6 +32,7 @@ from .permissions import IN_DOCKER
ConfigOverrides = Mapping[str, object]
ConfigPayload = dict[str, object]
PluginSchemaDocuments = dict[str, dict[str, Any]]
+LIVE_CONFIG_BASE_URL = "/admin/environment/config/"
###################### Config ##########################
@@ -64,6 +67,16 @@ def permissions_from_legacy_public_flags(raw_config: Mapping[str, object]) -> st
return None
+def resolve_delete_after_config_value(*configs: Mapping[str, Any] | None) -> str:
+ for config in configs:
+ if config is None:
+ continue
+ value = config.get("DELETE_AFTER")
+ if value:
+ return str(value)
+ return "0"
+
+
_SENSITIVE_CONFIG_KEY_NEEDLES = ("TOKEN", "SECRET", "API_KEY", "APIKEY", "PASSWORD")
SENSITIVE_CONFIG_VALUE_REDACTED = "********"
_SCOPE_CRAWL_FROZEN = "crawl_frozen"
@@ -118,8 +131,15 @@ 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."""
+def normalize_runtime_config(
+ config: BaseConfigSet | Mapping[str, Any] | str | None,
+ *,
+ only_crawl_execution: bool = False,
+ exclude_runtime_derived: bool = False,
+ exclude_crawl_execution: bool = False,
+ json_safe: bool = True,
+) -> dict[str, Any]:
+ """Return config filtered for runtime/frozen usage, optionally JSON-safe."""
if config is None:
return {}
if isinstance(config, BaseConfigSet):
@@ -128,21 +148,41 @@ def normalize_runtime_config(config: BaseConfigSet | Mapping[str, Any] | str | N
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}
+
+ runtime_derived_keys = ArchiveBoxConfig.runtime_derived_config_keys() if exclude_runtime_derived else frozenset()
+ filtered = {
+ key: value
+ for key, value in config.items()
+ if (
+ value is not None
+ and (not only_crawl_execution or ArchiveBoxConfig.scope_for_key(str(key)) == _SCOPE_CRAWL_EXECUTION)
+ and (not exclude_runtime_derived or str(key) not in runtime_derived_keys)
+ and (not exclude_crawl_execution or ArchiveBoxConfig.scope_for_key(str(key)) != _SCOPE_CRAWL_EXECUTION)
+ )
+ }
+ if not json_safe:
+ return filtered
+ return {key: value for key, value in json.loads(json.dumps(filtered, 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)
- frozen = effective.for_crawl_frozen()
+ """Build the frozen crawl config stored on Crawl.config at creation time."""
+ explicit_overrides = set(overrides or {})
+ plugin_owned_keys = set(_plugin_config_properties(PLUGIN_CONFIG_SCHEMAS)) - set(ArchiveBoxBaseConfig.model_fields)
+ effective = get_config(persona=persona, base_config=base_config)
+ frozen = effective.for_crawl_frozen(persona=persona)
if overrides:
- frozen = get_config(base_config=frozen, overrides=overrides, include_machine=False).for_crawl_frozen()
+ resolved = get_config(base_config=frozen, overrides=overrides, include_machine=False)
+ resolved_payload = normalize_runtime_config(resolved)
+ frozen = resolved.for_crawl_frozen(persona=persona)
+ for key in plugin_owned_keys & explicit_overrides:
+ if ArchiveBoxConfig.scope_for_key(key) == _SCOPE_CRAWL_FROZEN and key in resolved_payload:
+ frozen[key] = resolved_payload[key]
return frozen
@@ -187,11 +227,6 @@ 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)
- USERS_DIR: Path = Field(default=CONSTANTS.USERS_DIR)
- PERSONAS_DIR: Path = Field(default=CONSTANTS.PERSONAS_DIR)
-
# 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
@@ -206,10 +241,6 @@ class StorageConfig(BaseConfigSet):
# Runtime lookup must use provider-specific paths under LIB_DIR instead.
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")
ENFORCE_ATOMIC_WRITES: bool = Field(default=True)
ALLOW_NO_UNIX_SOCKETS: bool = Field(default=False, alias="ARCHIVEBOX_ALLOW_NO_UNIX_SOCKETS")
@@ -244,8 +275,6 @@ class ServerConfig(BaseConfigSet):
FOOTER_INFO: str = Field(
default="Content is hosted for personal archiving purposes only. Contact server owner for any takedown requests.",
)
- # CUSTOM_TEMPLATES_DIR: Path = Field(default=None) # this is now a constant
-
PUBLIC_INDEX: bool = Field(default=True)
PUBLIC_ADD_VIEW: bool = Field(default=False)
@@ -355,7 +384,7 @@ class ArchivingConfig(BaseConfigSet):
URL_DENYLIST: str = Field(default=r"\.(css|js|otf|ttf|woff|woff2|gstatic\.com|googleapis\.com/css)(\?.*)?$", alias="URL_BLACKLIST")
URL_ALLOWLIST: str | None = Field(default=None, alias="URL_WHITELIST")
- DEFAULT_PERSONA: str = Field(default="Default")
+ DEFAULT_PERSONA: str = Field(default="Default", json_schema_extra={"scope": _SCOPE_CRAWL_EXECUTION})
PERMISSIONS: str = Field(
default="public",
description="Snapshot visibility: public lists and serves content, unlisted serves direct links only, private requires admin login.",
@@ -469,9 +498,7 @@ def _discover_plugin_config_schemas() -> PluginSchemaDocuments:
schemas: PluginSchemaDocuments = {}
if BASE_CONFIG_PATH.exists():
- schemas["base"] = {
- "properties": json.loads(BASE_CONFIG_PATH.read_text()).get("properties", {}),
- }
+ schemas["base"] = json.loads(BASE_CONFIG_PATH.read_text())
schemas.update(discover_plugin_configs())
return schemas
@@ -524,10 +551,6 @@ class ArchiveBoxBaseConfig(
populate_by_name=True,
)
- 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
@@ -566,64 +589,146 @@ class ArchiveBoxBaseConfig(
@classmethod
def _plugin_field_scope(cls, key: str) -> str | None:
+ scope = 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
- return _SCOPE_CRAWL_FROZEN
- return None
+ scope = str(prop_schema["x-scope"])
+ elif scope is None:
+ if str(plugin_name).startswith("search_backend_"):
+ scope = _SCOPE_SERVER
+ else:
+ scope = _SCOPE_CRAWL_FROZEN
+ return scope
@classmethod
+ @lru_cache(maxsize=None)
def scope_for_key(cls, key: str) -> str:
+ for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items():
+ if str(plugin_name).startswith("search_backend_"):
+ continue
+ properties = schema.get("properties") if isinstance(schema, dict) else None
+ if isinstance(properties, dict) and key == f"{str(plugin_name).upper()}_ENABLED" and key in properties:
+ return _SCOPE_CRAWL_EXECUTION
+ if key.endswith("_BINARY"):
+ return _SCOPE_CRAWL_EXECUTION
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}
+ @classmethod
+ @lru_cache(maxsize=1)
+ def _scope_by_key(cls) -> dict[str, str]:
+ return {key: cls.scope_for_key(key) for key in cls.model_fields}
- def for_crawl_execution(self) -> dict[str, Any]:
- """Config safe to pass to crawl/snapshot hook execution."""
+ @classmethod
+ @lru_cache(maxsize=1)
+ def _crawl_frozen_keys(cls) -> frozenset[str]:
+ return frozenset(key for key, scope in cls._scope_by_key().items() if scope == _SCOPE_CRAWL_FROZEN)
+
+ @classmethod
+ @lru_cache(maxsize=1)
+ def _crawl_runtime_keys(cls) -> frozenset[str]:
+ return frozenset(key for key, scope in cls._scope_by_key().items() if scope in {_SCOPE_CRAWL_FROZEN, _SCOPE_CRAWL_EXECUTION})
+
+ @classmethod
+ @lru_cache(maxsize=1)
+ def runtime_derived_config_keys(cls) -> frozenset[str]:
+ runtime_derived_keys = {
+ "ABX_INSTALL_CACHE",
+ "ACTIVE_PERSONA",
+ "CHROME_DOWNLOADS_DIR",
+ "CHROME_USER_DATA_DIR",
+ "DEFAULT_PERSONA",
+ "EXTRA_CONTEXT",
+ }
+ return frozenset(
+ key for key, scope in cls._scope_by_key().items() if scope == _SCOPE_CRAWL_EXECUTION and key in runtime_derived_keys
+ )
+
+ def _scoped_config(self, *, include_execution: bool) -> dict[str, Any]:
+ keys = type(self)._crawl_runtime_keys() if include_execution else type(self)._crawl_frozen_keys()
+ payload = self.model_dump(mode="json")
+ return {key: payload[key] for key in keys if payload.get(key) is not None}
+
+ def for_crawl(self) -> dict[str, Any]:
+ """Config scoped to crawl execution, without runtime object overlays."""
return self._scoped_config(include_execution=True)
- def for_crawl_frozen(self) -> dict[str, Any]:
+ def for_crawl_frozen(self, *, persona: Any = None) -> dict[str, Any]:
"""Config safe to persist permanently on Crawl.config."""
- return self._scoped_config(include_execution=False)
+ frozen = self._scoped_config(include_execution=False)
+ if persona is not None:
+ persona_config = dict(persona.config or {})
+ scope_by_key = type(self)._scope_by_key()
+ for key in persona.get_derived_config():
+ if key not in persona_config and scope_by_key.get(key) == _SCOPE_CRAWL_EXECUTION:
+ frozen.pop(key, None)
+ return frozen
+
+ def for_crawl_runtime(
+ self,
+ *,
+ crawl: Any = None,
+ snapshot: Any = None,
+ persona: Any = None,
+ runtime_overrides: Mapping[str, Any] | None = None,
+ extra_context: Mapping[str, Any] | None = None,
+ crawl_output_dir: Any = None,
+ snapshot_output_dir: Any = None,
+ ) -> dict[str, Any]:
+ """Config payload safe to pass to crawl/snapshot hook execution."""
+ config = self.for_crawl()
+ scope_by_key = type(self)._scope_by_key()
+ model_fields = type(self).model_fields
+ for key in type(self).runtime_derived_config_keys():
+ config.pop(key, None)
+ if persona is not None:
+ for key, value in persona.get_derived_config().items():
+ if scope_by_key.get(key) == _SCOPE_CRAWL_EXECUTION:
+ config[key] = value
+
+ if crawl is not None:
+ for key, value in dict(crawl.config or {}).items():
+ if key in model_fields and scope_by_key.get(key) != _SCOPE_CRAWL_EXECUTION:
+ config[key] = value
+ config["CRAWL_DIR"] = str(crawl_output_dir if crawl_output_dir is not None else crawl.output_dir)
+
+ if snapshot is not None:
+ for key, value in dict(snapshot.config or {}).items():
+ if key in model_fields and scope_by_key.get(key) != _SCOPE_CRAWL_EXECUTION:
+ config[key] = value
+ config["SNAP_DIR"] = str(snapshot_output_dir if snapshot_output_dir is not None else snapshot.output_dir)
+
+ if runtime_overrides:
+ config.update(normalize_runtime_config(runtime_overrides, json_safe=False))
+
+ if extra_context:
+ context: dict[str, Any] = {}
+ if config.get("EXTRA_CONTEXT"):
+ parsed_extra_context = json.loads(str(config["EXTRA_CONTEXT"]))
+ if not isinstance(parsed_extra_context, dict):
+ raise TypeError("EXTRA_CONTEXT must decode to an object")
+ context = parsed_extra_context
+ context.update(dict(extra_context))
+ config["EXTRA_CONTEXT"] = json.dumps(context, separators=(",", ":"), sort_keys=True)
+
+ _derive_plugin_enabled_config(config)
+ return config
@model_validator(mode="after")
def resolve_runtime_paths(self):
- self.DATA_DIR = self.DATA_DIR.expanduser().resolve()
-
- archive_dir = self.ARCHIVE_DIR.expanduser()
- if archive_dir == (CONSTANTS.DATA_DIR / CONSTANTS.ARCHIVE_DIR_NAME) and self.DATA_DIR != CONSTANTS.DATA_DIR:
- archive_dir = self.DATA_DIR / CONSTANTS.ARCHIVE_DIR_NAME
- if not archive_dir.is_absolute():
- archive_dir = self.DATA_DIR / archive_dir
- self.ARCHIVE_DIR = archive_dir.resolve()
-
- users_dir = self.USERS_DIR.expanduser()
- if users_dir == (CONSTANTS.ARCHIVE_DIR / CONSTANTS.USERS_DIR_NAME):
- users_dir = self.ARCHIVE_DIR / CONSTANTS.USERS_DIR_NAME
- if not users_dir.is_absolute():
- users_dir = self.ARCHIVE_DIR / users_dir
- self.USERS_DIR = users_dir.resolve()
-
lib_dir = self.LIB_DIR.expanduser()
if not lib_dir.is_absolute():
- lib_dir = self.DATA_DIR / lib_dir
+ lib_dir = CONSTANTS.DATA_DIR / lib_dir
self.LIB_DIR = lib_dir.resolve()
lib_bin_dir = self.LIB_BIN_DIR.expanduser()
if lib_bin_dir == CONSTANTS.DEFAULT_LIB_BIN_DIR and self.LIB_DIR != CONSTANTS.DEFAULT_LIB_DIR:
lib_bin_dir = self.LIB_DIR / "bin"
elif not lib_bin_dir.is_absolute():
- lib_bin_dir = self.DATA_DIR / lib_bin_dir
+ lib_bin_dir = CONSTANTS.DATA_DIR / lib_bin_dir
self.LIB_BIN_DIR = lib_bin_dir.resolve()
return self
@@ -649,15 +754,204 @@ PLUGIN_CONFIG_SCHEMAS = _discover_plugin_config_schemas()
ArchiveBoxConfig = _build_archivebox_config_model(PLUGIN_CONFIG_SCHEMAS)
+def _normalize_plugins_config_value(value: Any) -> set[str]:
+ if value is None:
+ return set()
+ if isinstance(value, str):
+ raw = value.strip()
+ if not raw:
+ return set()
+ if raw.startswith("["):
+ try:
+ parsed = json.loads(raw)
+ except json.JSONDecodeError:
+ parsed = None
+ if isinstance(parsed, list):
+ return {str(plugin).strip().lower() for plugin in parsed if str(plugin).strip()}
+ return {plugin.strip().lower() for plugin in raw.split(",") if plugin.strip()}
+ if isinstance(value, (list, tuple, set)):
+ return {str(plugin).strip().lower() for plugin in value if str(plugin).strip()}
+ normalized = str(value).strip().lower()
+ return {normalized} if normalized else set()
+
+
+@lru_cache(maxsize=1)
+def _plugin_enabled_config_keys() -> dict[str, str]:
+ enabled_keys: dict[str, str] = {}
+ for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items():
+ properties = schema.get("properties") if isinstance(schema, dict) else None
+ if not isinstance(properties, dict):
+ continue
+ enabled_key = f"{str(plugin_name).upper()}_ENABLED"
+ if enabled_key in properties and ArchiveBoxConfig.scope_for_key(enabled_key) == _SCOPE_CRAWL_EXECUTION:
+ enabled_keys[str(plugin_name).lower()] = enabled_key
+ return enabled_keys
+
+
+def _plugins_with_required_plugins(plugin_names: set[str]) -> set[str]:
+ selected = set(plugin_names)
+ pending = list(selected)
+ while pending:
+ plugin_name = pending.pop()
+ schema = PLUGIN_CONFIG_SCHEMAS.get(plugin_name, {})
+ required_plugins = schema.get("required_plugins") if isinstance(schema, dict) else None
+ if not isinstance(required_plugins, list):
+ continue
+ for required_plugin in required_plugins:
+ required_plugin_name = str(required_plugin).strip().lower()
+ if required_plugin_name and required_plugin_name not in selected:
+ selected.add(required_plugin_name)
+ pending.append(required_plugin_name)
+ return selected
+
+
+def _derive_plugin_enabled_config(config: dict[str, Any]) -> None:
+ plugin_names = _normalize_plugins_config_value(config.get("PLUGINS"))
+ if not plugin_names:
+ return
+ selected_plugins = _plugins_with_required_plugins(plugin_names)
+ for plugin_name, enabled_key in _plugin_enabled_config_keys().items():
+ config[enabled_key] = plugin_name in selected_plugins
+
+
+def get_live_config_url(key: str) -> str:
+ return f"{LIVE_CONFIG_BASE_URL}{quote(key)}/"
+
+
+@lru_cache(maxsize=1)
+def config_field_metadata() -> dict[str, dict[str, Any]]:
+ """Return one centralized metadata map for core and plugin config fields."""
+ metadata: dict[str, dict[str, Any]] = {}
+ for key, field in ArchiveBoxConfig.model_fields.items():
+ if ArchiveBoxConfig.scope_for_key(key) == _SCOPE_CRAWL_EXECUTION or key in ArchiveBoxConfig.computed_config_keys:
+ continue
+ default = field.default
+ try:
+ json.dumps(default)
+ except TypeError:
+ default = str(default)
+ metadata[key] = {
+ "plugin": "archivebox",
+ "section": find_config_section(key),
+ "type": config_field_type(key),
+ "default": default,
+ "description": field.description or "",
+ "scope": ArchiveBoxConfig.scope_for_key(key),
+ "sensitive": is_sensitive_config_key(key),
+ }
+ for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items():
+ properties = schema.get("properties") if isinstance(schema, dict) else None
+ if not isinstance(properties, dict):
+ continue
+ for key, prop in properties.items():
+ if not isinstance(prop, Mapping):
+ continue
+ if ArchiveBoxConfig.scope_for_key(key) == _SCOPE_CRAWL_EXECUTION:
+ continue
+ metadata[key] = {
+ **metadata.get(key, {}),
+ "plugin": plugin_name,
+ "section": "PLUGINS",
+ "type": prop.get("type", metadata.get(key, {}).get("type", "string")),
+ "default": prop.get("default", metadata.get(key, {}).get("default", "")),
+ "description": prop.get("description", metadata.get(key, {}).get("description", "")),
+ "scope": ArchiveBoxConfig.scope_for_key(key),
+ "sensitive": bool(prop.get("x-sensitive")) or is_sensitive_config_key(key),
+ "schema": dict(prop),
+ }
+ return metadata
+
+
+def find_config_section(key: str) -> str:
+ from archivebox.config import CONSTANTS_CONFIG
+
+ if key in CONSTANTS_CONFIG:
+ return "CONSTANT"
+ for section_id, section in get_all_configs().items():
+ if key in type(section).model_fields:
+ return section_id
+ if key in _plugin_config_properties(PLUGIN_CONFIG_SCHEMAS):
+ return "PLUGINS"
+ return "DYNAMIC"
+
+
+def find_config_default(key: str) -> str:
+ from archivebox.config import CONSTANTS_CONFIG
+
+ if key in CONSTANTS_CONFIG:
+ return str(CONSTANTS_CONFIG[key])
+
+ field = ArchiveBoxConfig.model_fields.get(key)
+ if field is None:
+ return ""
+ default_val = field.default
+ if callable(default_val):
+ default_val = inspect.getsource(default_val).split("lambda", 1)[-1].split(":", 1)[-1].replace("\n", " ").strip()
+ if default_val.count(")") > default_val.count("("):
+ default_val = default_val[:-1]
+ else:
+ default_val = str(default_val)
+ return default_val
+
+
+def config_field_type(key: str) -> str:
+ field = ArchiveBoxConfig.model_fields.get(key)
+ if field is None:
+ return "str"
+ annotation = field.annotation
+ try:
+ return annotation.__name__
+ except AttributeError:
+ return str(annotation)
+
+
+def find_config_type(key: str) -> str:
+ return config_field_type(key)
+
+
+def find_config_source(key: str, merged_config: Mapping[str, Any]) -> str:
+ """Determine where a config value comes from."""
+ try:
+ from archivebox.machine.models import Machine
+
+ machine = Machine.current()
+ if machine.config and key in machine.config:
+ return "Machine"
+ except Exception:
+ pass
+
+ if key in os.environ:
+ return "Environment"
+
+ file_config = BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE)
+ if key in file_config:
+ return "File"
+
+ if key in _plugin_config_properties(PLUGIN_CONFIG_SCHEMAS):
+ return "Plugin Default"
+
+ return "Default"
+
+
+def get_request_config(request: Any, *, resolve_plugins: bool = False) -> ArchiveBoxBaseConfig:
+ """Return the per-request ArchiveBox config, upgrading to plugin resolution if needed."""
+ request_state = request.__dict__
+ request_config = request_state.get("archivebox_config")
+ request_config_resolves_plugins = bool(request_state.get("_archivebox_config_resolves_plugins", False))
+ if request_config is None or (resolve_plugins and not request_config_resolves_plugins):
+ request_config = get_config(resolve_plugins=resolve_plugins)
+ request.archivebox_config = request_config
+ request._archivebox_config_resolves_plugins = resolve_plugins
+ return request_config
+
+
def get_config(
defaults: ConfigOverrides | None = None,
overrides: ConfigOverrides | None = None,
base_config: ArchiveBoxBaseConfig | Mapping[str, object] | None = None,
persona: Any = None,
- user: Any = None,
crawl: Any = None,
snapshot: Any = None,
- archiveresult: Any = None,
machine: Any = None,
include_machine: bool = True,
resolve_plugins: bool = True,
@@ -666,28 +960,20 @@ def get_config(
"""
Get merged config from all sources.
- Priority (highest to lowest):
- 1. Explicit overrides
- 2. Per-ArchiveResult config
- 3. Per-snapshot config and output path
- 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
- """
- if snapshot is None and archiveresult is not None:
- snapshot = archiveresult.snapshot
+ Defaults are hydrated by pydantic from core/plugin defaults,
+ ArchiveBox.conf, and environment variables. Persisted Machine/Persona
+ values then apply for live crawl-execution scope, while Crawl/Snapshot
+ rows apply their frozen crawl-scope values. Explicit overrides win last.
+ Crawl-execution config is not persisted on Crawl.config. It is rederived
+ from current Machine/Persona state and hydrated process defaults each time.
+ """
if crawl is None and snapshot is not None:
crawl = snapshot.crawl
crawl_config_base = crawl is not None and base_config is None
- if include_machine and machine is None and not crawl_config_base:
+ if include_machine and machine is None:
try:
from django.apps import apps
@@ -698,58 +984,62 @@ def get_config(
except Exception:
machine = None
- if persona is None and crawl is not None and not crawl_config_base:
+ if persona is None and crawl is not None:
persona = crawl.resolve_persona()
config_data: ConfigPayload = dict(defaults or {})
base_config_payload: ConfigPayload = {}
if crawl_config_base:
- config_data.update(dict(crawl.config or {}))
+ config_data.update(
+ normalize_runtime_config(ArchiveBoxConfig().model_dump(mode="json"), exclude_runtime_derived=True, json_safe=False),
+ )
+ config_data.update(normalize_runtime_config(dict(crawl.config or {}), exclude_crawl_execution=True, json_safe=False))
elif base_config is not None:
if isinstance(base_config, ArchiveBoxBaseConfig):
base_config_payload.update(base_config.model_dump(mode="json"))
else:
base_config_payload.update(dict(base_config))
- config_data.update(base_config_payload)
+ config_data.update(normalize_runtime_config(base_config_payload, exclude_runtime_derived=True, json_safe=False))
else:
- config_data.update(ArchiveBoxConfig().model_dump(mode="json"))
+ config_data.update(
+ normalize_runtime_config(ArchiveBoxConfig().model_dump(mode="json"), exclude_runtime_derived=True, json_safe=False),
+ )
legacy_permissions = permissions_from_legacy_public_flags({**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **os.environ})
if legacy_permissions:
config_data["PERMISSIONS"] = legacy_permissions
scope_overrides: ConfigPayload = {}
- if not crawl_config_base:
- if include_machine and machine is not None and machine.config:
- from archivebox.machine.models import _sanitize_machine_config
+ 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(
+ normalize_runtime_config(
+ _sanitize_machine_config(machine.config, lib_dir=config_data.get("LIB_DIR")),
+ only_crawl_execution=crawl_config_base,
+ exclude_runtime_derived=True,
+ json_safe=False,
+ ),
+ )
- 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 persona is not None:
+ scope_overrides.update(
+ normalize_runtime_config(
+ persona.get_derived_config(),
+ only_crawl_execution=crawl_config_base,
+ exclude_runtime_derived=not crawl_config_base,
+ json_safe=False,
+ ),
+ )
if crawl is not None and crawl.config and not crawl_config_base:
- scope_overrides.update(crawl.config)
-
- if crawl is not None:
- if not overrides or "CRAWL_DIR" not in overrides:
- scope_overrides["CRAWL_DIR"] = crawl.output_dir
+ scope_overrides.update(normalize_runtime_config(crawl.config, exclude_crawl_execution=True, json_safe=False))
if snapshot is not None and snapshot.config:
- scope_overrides.update(snapshot.config)
-
- if snapshot is not None:
- scope_overrides["SNAP_DIR"] = snapshot.output_dir
-
- if archiveresult is not None and archiveresult.config:
- scope_overrides.update(archiveresult.config)
+ scope_overrides.update(normalize_runtime_config(snapshot.config, exclude_crawl_execution=True, json_safe=False))
if overrides:
- scope_overrides.update(overrides)
+ scope_overrides.update(normalize_runtime_config(overrides, exclude_crawl_execution=True, json_safe=False))
legacy_scope_permissions = permissions_from_legacy_public_flags(scope_overrides)
if legacy_scope_permissions:
@@ -759,27 +1049,47 @@ def get_config(
config_data.update(archivebox_scope_overrides)
if resolve_plugins:
- plugin_schemas = {
- plugin_name: schema.get("properties", {}) for plugin_name, schema in PLUGIN_CONFIG_SCHEMAS.items() if isinstance(schema, dict)
- }
+ plugin_schemas = {plugin_name: schema 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)
+ plugin_user_config = _plugin_user_config(
+ {
+ **normalize_runtime_config(config_data, only_crawl_execution=True, json_safe=False),
+ **scope_overrides,
+ },
+ )
if not crawl_config_base:
- plugin_user_config = {**BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE), **plugin_user_config}
+ plugin_user_config = {
+ **normalize_runtime_config(
+ BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE),
+ exclude_runtime_derived=True,
+ json_safe=False,
+ ),
+ **plugin_user_config,
+ }
plugin_sections = resolve_plugin_configs(
plugin_schemas,
global_config=plugin_global_config,
user_config=plugin_user_config,
+ environ={},
)
for plugin_config in plugin_sections.values():
- config_data.update(plugin_config)
+ for key, value in plugin_config.items():
+ if key in ArchiveBoxBaseConfig.model_fields and key not in archivebox_scope_overrides and key not in base_config_payload:
+ continue
+ config_data[key] = value
if base_config_payload:
- config_data.update({key: value for key, value in base_config_payload.items() if key in _archivebox_config_input_names()})
+ config_data.update(
+ {
+ key: value
+ for key, value in normalize_runtime_config(base_config_payload, exclude_runtime_derived=True, json_safe=False).items()
+ if key in _archivebox_config_input_names()
+ },
+ )
if crawl_config_base:
- config_data.update(dict(crawl.config or {}))
+ config_data.update(normalize_runtime_config(dict(crawl.config or {}), exclude_crawl_execution=True, json_safe=False))
config_data.update(archivebox_scope_overrides)
- config_data["ABX_RUNTIME"] = "archivebox"
+ _derive_plugin_enabled_config(config_data)
# Decode JSON-encoded complex values (dict/list fields) that came from
# string-only sources before validation. ``IniConfigSettingsSource`` does
@@ -807,7 +1117,7 @@ def get_config(
config = ArchiveBoxConfig.model_validate(config_data)
if redact_sensitive:
for key in type(config).model_fields:
- value = getattr(config, key, None)
+ value = config[key]
if is_sensitive_config_key(key) and value not in (None, ""):
setattr(config, key, SENSITIVE_CONFIG_VALUE_REDACTED)
os.environ["LIB_DIR"] = str(config.LIB_DIR)
diff --git a/archivebox/config/paths.py b/archivebox/config/paths.py
index 4b66f409..734822c9 100644
--- a/archivebox/config/paths.py
+++ b/archivebox/config/paths.py
@@ -19,7 +19,7 @@ if TYPE_CHECKING:
#############################################################################################
PACKAGE_DIR: Path = Path(__file__).resolve().parent.parent # archivebox source code dir
-DATA_DIR: Path = Path(os.environ.get("DATA_DIR", os.getcwd())).resolve() # archivebox user data dir
+DATA_DIR: Path = Path(os.getcwd()).resolve() # archivebox user data dir
def _env_path(key: str, default: Path) -> Path:
@@ -29,8 +29,8 @@ def _env_path(key: str, default: Path) -> Path:
return path.resolve()
-ARCHIVE_DIR: Path = _env_path("ARCHIVE_DIR", DATA_DIR / "archive") # archivebox snapshot data dir
-USERS_DIR: Path = _env_path("USERS_DIR", ARCHIVE_DIR / "users") # archivebox user-scoped crawl/snapshot data dir
+ARCHIVE_DIR: Path = DATA_DIR / "archive" # archivebox snapshot data dir
+USERS_DIR: Path = ARCHIVE_DIR / "users" # archivebox user-scoped crawl/snapshot data dir
IN_DOCKER = os.environ.get("IN_DOCKER", False) in ("1", "true", "True", "TRUE", "yes")
@@ -290,20 +290,20 @@ def get_data_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs
"is_mount": os.path.ismount(DATABASE_FILE.resolve()),
},
"ARCHIVE_DIR": {
- "path": config.ARCHIVE_DIR.resolve(),
+ "path": CONSTANTS.ARCHIVE_DIR.resolve(),
"enabled": True,
- "is_valid": os.path.isdir(config.ARCHIVE_DIR)
- and os.access(config.ARCHIVE_DIR, os.R_OK)
- and os.access(config.ARCHIVE_DIR, os.W_OK),
- "is_mount": os.path.ismount(config.ARCHIVE_DIR.resolve()),
+ "is_valid": os.path.isdir(CONSTANTS.ARCHIVE_DIR)
+ and os.access(CONSTANTS.ARCHIVE_DIR, os.R_OK)
+ and os.access(CONSTANTS.ARCHIVE_DIR, os.W_OK),
+ "is_mount": os.path.ismount(CONSTANTS.ARCHIVE_DIR.resolve()),
},
"USERS_DIR": {
- "path": config.USERS_DIR.resolve(),
- "enabled": os.path.isdir(config.USERS_DIR),
- "is_valid": os.path.isdir(config.USERS_DIR)
- and os.access(config.USERS_DIR, os.R_OK)
- and os.access(config.USERS_DIR, os.W_OK),
- "is_mount": os.path.ismount(config.USERS_DIR.resolve()),
+ "path": CONSTANTS.USERS_DIR.resolve(),
+ "enabled": os.path.isdir(CONSTANTS.USERS_DIR),
+ "is_valid": os.path.isdir(CONSTANTS.USERS_DIR)
+ and os.access(CONSTANTS.USERS_DIR, os.R_OK)
+ and os.access(CONSTANTS.USERS_DIR, os.W_OK),
+ "is_mount": os.path.ismount(CONSTANTS.USERS_DIR.resolve()),
},
"SOURCES_DIR": {
"path": CONSTANTS.SOURCES_DIR.resolve(),
@@ -366,9 +366,9 @@ def get_code_locations(config: "ArchiveBoxConfig | None" = None, **config_kwargs
"is_valid": os.access(CONSTANTS.STATIC_DIR, os.R_OK) and os.access(CONSTANTS.STATIC_DIR, os.X_OK), # read + list
},
"CUSTOM_TEMPLATES_DIR": {
- "path": config.CUSTOM_TEMPLATES_DIR.resolve(),
- "enabled": os.path.isdir(config.CUSTOM_TEMPLATES_DIR),
- "is_valid": os.path.isdir(config.CUSTOM_TEMPLATES_DIR) and os.access(config.CUSTOM_TEMPLATES_DIR, os.R_OK), # read
+ "path": CONSTANTS.CUSTOM_TEMPLATES_DIR.resolve(),
+ "enabled": os.path.isdir(CONSTANTS.CUSTOM_TEMPLATES_DIR),
+ "is_valid": os.path.isdir(CONSTANTS.CUSTOM_TEMPLATES_DIR) and os.access(CONSTANTS.CUSTOM_TEMPLATES_DIR, os.R_OK), # read
},
"USER_PLUGINS_DIR": {
"path": CONSTANTS.USER_PLUGINS_DIR.resolve(),
diff --git a/archivebox/config/permissions.py b/archivebox/config/permissions.py
index 132869cf..45afb3ca 100644
--- a/archivebox/config/permissions.py
+++ b/archivebox/config/permissions.py
@@ -15,8 +15,6 @@ from contextlib import contextmanager
#############################################################################################
DATA_DIR = Path(os.getcwd())
-if os.environ.get("DATA_DIR") and Path(os.environ["DATA_DIR"]).resolve() != DATA_DIR.resolve():
- raise SystemExit(f"[X] DATA_DIR={os.environ['DATA_DIR']} must equal cwd={DATA_DIR}; cd into the data dir before running archivebox")
try:
DATA_DIR_STAT = DATA_DIR.stat()
diff --git a/archivebox/config/views.py b/archivebox/config/views.py
index f95a0ae3..d68cac45 100644
--- a/archivebox/config/views.py
+++ b/archivebox/config/views.py
@@ -24,7 +24,7 @@ INSTALLED_BINARIES_BASE_URL = "/admin/machine/binary/"
def is_superuser(request: HttpRequest) -> bool:
- return bool(getattr(request.user, "is_superuser", False))
+ return bool(request.user.is_superuser)
def format_parsed_datetime(value: object) -> str:
@@ -44,7 +44,7 @@ def get_installed_binary_change_url(name: str, binary: Binary | None) -> str | N
if binary is None or not binary.id:
return None
- base_url = getattr(binary, "admin_change_url", None) or f"{INSTALLED_BINARIES_BASE_URL}{binary.id}/change/"
+ base_url = binary.admin_change_url
changelist_filters = urlencode({"q": name})
return f"{base_url}?{urlencode({'_changelist_filters': changelist_filters})}"
@@ -158,7 +158,7 @@ def binaries_list_view(request: HttpRequest, **kwargs) -> TableContext:
for name in all_binary_names:
binary = db_binaries.get(name)
- binary_is_valid = bool(binary and getattr(binary, "is_valid", getattr(binary, "abspath", None)))
+ binary_is_valid = bool(binary and binary.is_valid)
rows["Binary Name"].append(ItemLink(name, key=name))
@@ -185,9 +185,9 @@ def binary_detail_view(request: HttpRequest, key: str, **kwargs) -> ItemContext:
"youtube-dl": "yt-dlp",
}.get(key, key)
db_binary = get_db_binaries_by_name().get(key)
- binary_is_valid = bool(db_binary and getattr(db_binary, "is_valid", getattr(db_binary, "abspath", None)))
+ binary_is_valid = bool(db_binary and db_binary.is_valid)
if binary_is_valid:
- binary_data = db_binary.to_json() if hasattr(db_binary, "to_json") else db_binary.__dict__
+ binary_data = db_binary.to_json()
section: SectionData = {
"name": key,
"description": mark_safe(render_binary_detail_description(key, binary_data, db_binary)),
diff --git a/archivebox/core/admin_archiveresults.py b/archivebox/core/admin_archiveresults.py
index bf803abc..07ed8609 100644
--- a/archivebox/core/admin_archiveresults.py
+++ b/archivebox/core/admin_archiveresults.py
@@ -19,8 +19,6 @@ from django.urls import reverse, resolve
from django.utils import timezone
from django.utils.text import smart_split
-from archivebox.config import DATA_DIR
-from archivebox.config.common import get_config
from archivebox.misc.paginators import AcceleratedPaginator
from archivebox.base_models.admin import BaseModelAdmin
from archivebox.plugins.discovery import get_plugin_icon
@@ -35,7 +33,7 @@ from archivebox.core.models import ArchiveResult, Snapshot
def _get_replay_source_url(result: ArchiveResult) -> str:
process = result.process_record
- return str(getattr(process, "url", None) or result.snapshot.url or "")
+ return str((process.url if process else None) or result.snapshot.url or "")
def build_abx_dl_display_command(result: ArchiveResult) -> str:
@@ -51,8 +49,8 @@ def build_abx_dl_display_command(result: ArchiveResult) -> str:
def build_abx_dl_replay_command(result: ArchiveResult, config=None) -> str:
display_command = build_abx_dl_display_command(result)
- process = getattr(result, "process", None)
- env_items = env_to_shell_exports(getattr(process, "env", None) or {})
+ process = result.process
+ env_items = env_to_shell_exports(process.env if process else {})
if config is not None:
result.snapshot._runtime_config = config
snapshot_dir = shlex.quote(str(result.pwd or result.snapshot_dir))
@@ -164,8 +162,8 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
cmd_attr = html.escape(replay_cmd, quote=True)
# Build output link - use embed_path() which checks output_files first
- embed_path = result.embed_path() if hasattr(result, "embed_path") else None
- snapshot_id = str(getattr(result, "snapshot_id", ""))
+ embed_path = result.embed_path()
+ snapshot_id = str(result.snapshot_id)
if embed_path and result.status == "succeeded":
output_link = build_snapshot_url(snapshot_id, embed_path, config=config)
else:
@@ -354,8 +352,7 @@ class ArchiveResultInline(admin.TabularInline):
def get_formset(self, request, obj=None, **kwargs):
formset = super().get_formset(request, obj, **kwargs)
snapshot = self.get_parent_object_from_request(request)
- form_class = getattr(formset, "form", None)
- base_fields = getattr(form_class, "base_fields", {})
+ base_fields = formset.form.base_fields
snapshot_output_dir = str(snapshot.output_dir) if snapshot else ""
# import ipdb; ipdb.set_trace()
@@ -491,12 +488,10 @@ class ArchiveResultAdmin(BaseModelAdmin):
def change_view(self, request, object_id, form_url="", extra_context=None):
self.request = request
- request.archivebox_config = getattr(request, "archivebox_config", None) or get_config()
return super().change_view(request, object_id, form_url, extra_context)
def changelist_view(self, request, extra_context=None):
self.request = request
- request.archivebox_config = getattr(request, "archivebox_config", None) or get_config()
saved_list_per_page = self.list_per_page
self.list_per_page = request.archivebox_config.SNAPSHOTS_PER_PAGE
try:
@@ -573,20 +568,20 @@ class ArchiveResultAdmin(BaseModelAdmin):
return queryset.filter(reduce(and_, filters)).distinct(), True
def get_snapshot_view_url(self, result: ArchiveResult) -> str:
- request = getattr(self, "request", None)
- return build_snapshot_url(str(result.snapshot_id), request=request, config=getattr(request, "archivebox_config", None))
+ request = self.request
+ return build_snapshot_url(str(result.snapshot_id), request=request, config=request.archivebox_config)
def get_output_view_url(self, result: ArchiveResult) -> str:
- request = getattr(self, "request", None)
- config = getattr(request, "archivebox_config", None)
- output_path = result.embed_path() if hasattr(result, "embed_path") else None
+ request = self.request
+ config = request.archivebox_config
+ output_path = result.embed_path()
if not output_path:
output_path = result.plugin or ""
return build_snapshot_url(str(result.snapshot_id), output_path, request=request, config=config)
def get_output_files_url(self, result: ArchiveResult) -> str:
- request = getattr(self, "request", None)
- return f"{build_snapshot_url(str(result.snapshot_id), result.plugin, request=request, config=getattr(request, 'archivebox_config', None))}/?files=1"
+ request = self.request
+ return f"{build_snapshot_url(str(result.snapshot_id), result.plugin, request=request, config=request.archivebox_config)}/?files=1"
def get_output_zip_url(self, result: ArchiveResult) -> str:
return f"{self.get_output_files_url(result)}&download=zip"
@@ -612,10 +607,10 @@ class ArchiveResultAdmin(BaseModelAdmin):
)
def snapshot_info(self, result):
snapshot_id = str(result.snapshot_id)
- request = getattr(self, "request", None)
+ request = self.request
return format_html(
'[{}] {} {}
',
- build_snapshot_url(snapshot_id, "index.html", request=request, config=getattr(request, "archivebox_config", None)),
+ build_snapshot_url(snapshot_id, "index.html", request=request, config=request.archivebox_config),
snapshot_id[:8],
result.snapshot.bookmarked_at.strftime("%Y-%m-%d %H:%M"),
result.snapshot.url[:128],
@@ -687,9 +682,9 @@ class ArchiveResultAdmin(BaseModelAdmin):
@admin.display(description="Command")
def cmd_str(self, result):
- request = getattr(self, "request", None)
+ request = self.request
display_cmd = build_abx_dl_display_command(result)
- replay_cmd = build_abx_dl_replay_command(result, config=getattr(request, "archivebox_config", None))
+ replay_cmd = build_abx_dl_replay_command(result, config=request.archivebox_config)
return format_html(
"""
@@ -710,10 +705,10 @@ class ArchiveResultAdmin(BaseModelAdmin):
)
def output_display(self, result):
- request = getattr(self, "request", None)
- config = getattr(request, "archivebox_config", None)
+ request = self.request
+ config = request.archivebox_config
# Determine output link path - use embed_path() which checks output_files
- embed_path = result.embed_path() if hasattr(result, "embed_path") else None
+ embed_path = result.embed_path()
output_path = embed_path if (result.status == "succeeded" and embed_path) else "index.html"
snapshot_id = str(result.snapshot_id)
return format_html(
@@ -728,12 +723,12 @@ class ArchiveResultAdmin(BaseModelAdmin):
if not output_text:
return "-"
- request = getattr(self, "request", None)
- live_path = result.embed_path() if hasattr(result, "embed_path") else None
+ request = self.request
+ live_path = result.embed_path()
if live_path:
return format_html(
'
{}',
- build_snapshot_url(str(result.snapshot_id), live_path, request=request, config=getattr(request, "archivebox_config", None)),
+ build_snapshot_url(str(result.snapshot_id), live_path, request=request, config=request.archivebox_config),
output_text,
output_text,
)
@@ -785,18 +780,18 @@ class ArchiveResultAdmin(BaseModelAdmin):
)
def output_summary(self, result):
- snapshot_dir = Path(DATA_DIR) / str(result.pwd).split("data/", 1)[-1]
+ snapshot_dir = Path(result.snapshot.output_dir)
output_html = format_html(
'
{}',
result.output_str_for_display(),
)
snapshot_id = str(result.snapshot_id)
- request = getattr(self, "request", None)
+ request = self.request
output_html += format_html(
'
See result files ...',
- build_snapshot_url(snapshot_id, "index.html", request=request, config=getattr(request, "archivebox_config", None)),
+ build_snapshot_url(snapshot_id, "index.html", request=request, config=request.archivebox_config),
)
- embed_path = result.embed_path() if hasattr(result, "embed_path") else ""
+ embed_path = result.embed_path() or ""
path_from_embed = snapshot_dir / (embed_path or "")
output_html += format_html(
'{}/{}
',
diff --git a/archivebox/core/admin_snapshots.py b/archivebox/core/admin_snapshots.py
index fbf227bf..de9dbbbb 100644
--- a/archivebox/core/admin_snapshots.py
+++ b/archivebox/core/admin_snapshots.py
@@ -200,7 +200,6 @@ class SnapshotResultHealthListFilter(admin.SimpleListFilter):
("failed", ">50% failed"),
("running", ">50% running"),
("pending", ">50% queued"),
- ("backoff", ">50% waiting to retry"),
("noresults", ">50% noresults"),
)
@@ -216,7 +215,7 @@ class SnapshotResultHealthListFilter(admin.SimpleListFilter):
"succeeded": ArchiveResult.StatusChoices.SUCCEEDED,
"failed": ArchiveResult.StatusChoices.FAILED,
"running": ArchiveResult.StatusChoices.STARTED,
- "pending": ArchiveResult.StatusChoices.QUEUED,
+ "pending": (ArchiveResult.StatusChoices.QUEUED, ArchiveResult.StatusChoices.BACKOFF),
"backoff": ArchiveResult.StatusChoices.BACKOFF,
"noresults": ArchiveResult.StatusChoices.NORESULTS,
}
@@ -226,21 +225,28 @@ class SnapshotResultHealthListFilter(admin.SimpleListFilter):
# "succeeded" is overwhelmingly common in large collections.
# Scan Snapshots in admin order and do indexed per-snapshot
# probes so page 1 can stop after list_per_page matches.
- return queryset.alias(
+ queryset = queryset.alias(
total_results=ArchiveResult.snapshot_count_expr(),
matching_results=ArchiveResult.snapshot_count_expr(status=status),
).filter(matching_results__gt=F("total_results") / 2)
+ queryset._archivebox_count_hint = "model_estimate"
+ queryset.query._archivebox_count_hint = queryset._archivebox_count_hint
+ return queryset
# Rare statuses are faster status-first: use the
# (status, snapshot_id) index to find candidate snapshots.
- return queryset.filter(pk__in=ArchiveResult.snapshot_ids_with_majority_status(status))
+ snapshot_ids = ArchiveResult.cached_snapshot_ids_with_majority_status(status)
+ queryset = queryset.filter(pk__in=snapshot_ids)
+ queryset._archivebox_count_hint = len(snapshot_ids)
+ queryset.query._archivebox_count_hint = queryset._archivebox_count_hint
+ return queryset
return queryset
class SnapshotChangeList(SearchResultsChangeList):
def __init__(self, request, *args, **kwargs):
super().__init__(request, *args, **kwargs)
- resolver_name = getattr(getattr(request, "resolver_match", None), "url_name", "")
+ resolver_name = request.resolver_match.url_name
self.embedded_changelist = request.GET.get("_embedded") == "crawl"
self.snapshot_is_grid_view = not self.embedded_changelist and (
resolver_name == "grid" or request.path.rstrip("/").endswith("/grid")
@@ -323,9 +329,7 @@ class SnapshotAdminForm(forms.ModelForm):
# Handle tags_editor field
if commit:
instance.save()
- save_m2m = getattr(self, "_save_m2m", None)
- if callable(save_m2m):
- save_m2m()
+ self._save_m2m()
# Parse and save tags from tags_editor
tags_str = self.cleaned_data.get("tags_editor", "")
@@ -489,7 +493,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
return super().get_ordering(request)
def change_view(self, request, object_id, form_url="", extra_context=None):
- request.archivebox_config = getattr(request, "archivebox_config", None) or get_config()
+ self.request = request
extra_context = extra_context or {}
extra_context["CONFIG"] = request.archivebox_config
snapshot = self.get_object(request, object_id)
@@ -504,7 +508,6 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
def changelist_view(self, request, extra_context=None):
self.request = request
- request.archivebox_config = getattr(request, "archivebox_config", None) or get_config()
saved_list_per_page = self.list_per_page
embedded_changelist = request.GET.get("_embedded") == "crawl"
if embedded_changelist:
@@ -540,12 +543,12 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
return super().lookup_allowed(lookup, value, request=request)
def get_snapshot_view_url(self, obj: Snapshot) -> str:
- request = getattr(self, "request", None)
- return build_snapshot_url(str(obj.id), request=request, config=getattr(request, "archivebox_config", None))
+ request = self.request
+ return build_snapshot_url(str(obj.id), request=request, config=request.archivebox_config)
def get_snapshot_files_url(self, obj: Snapshot) -> str:
- request = getattr(self, "request", None)
- return f"{build_snapshot_url(str(obj.id), request=request, config=getattr(request, 'archivebox_config', None))}/?files=1"
+ request = self.request
+ return f"{build_snapshot_url(str(obj.id), request=request, config=request.archivebox_config)}/?files=1"
def get_snapshot_zip_url(self, obj: Snapshot) -> str:
return f"{self.get_snapshot_files_url(obj)}&download=zip"
@@ -650,7 +653,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
ordering_fields = self._get_ordering_fields(request)
needs_files_sort = "files" in ordering_fields
needs_tags_sort = "tags_inline" in ordering_fields
- is_change_view = getattr(getattr(request, "resolver_match", None), "url_name", "") == "core_snapshot_change"
+ is_change_view = request.resolver_match.url_name == "core_snapshot_change"
prefetches = ["tags"]
if is_change_view:
prefetches.append(
@@ -702,7 +705,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
@admin.display(description="👁", ordering="permissions")
def permissions_badge(self, obj):
- permissions = getattr(obj, "snapshot_permissions", None)
+ permissions = obj.__dict__.get("snapshot_permissions")
if permissions is None:
if obj.permissions:
permissions = obj.permissions
@@ -872,8 +875,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
)
def status_info(self, obj):
- request = getattr(self, "request", None)
- config = getattr(request, "archivebox_config", None)
+ request = self.request
+ config = request.archivebox_config
favicon_url = build_snapshot_url(str(obj.id), "favicon.ico", request=request, config=config)
return format_html(
"""
@@ -890,16 +893,16 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
@admin.display(description="Archive Results")
def archiveresults_list(self, obj):
- request = getattr(self, "request", None)
- return render_archiveresults_list(obj.archiveresult_set.all(), limit=8, config=getattr(request, "archivebox_config", None))
+ request = self.request
+ return render_archiveresults_list(obj.archiveresult_set.all(), limit=8, config=request.archivebox_config)
@admin.display(
description="Title",
ordering="title",
)
def title_str(self, obj):
- request = getattr(self, "request", None)
- config = getattr(request, "archivebox_config", None)
+ request = self.request
+ config = request.archivebox_config
title_raw = (obj.title or "").strip()
url_raw = (obj.url or "").strip()
title_normalized = title_raw.lower()
@@ -952,8 +955,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
return mark_safe(f'{tags_html}')
def _get_preview_data(self, obj):
- request = getattr(self, "request", None)
- config = getattr(request, "archivebox_config", None)
+ request = self.request
+ config = request.archivebox_config
results = self._get_prefetched_results(obj)
if results is not None:
has_screenshot = any(r.plugin == "screenshot" for r in results)
@@ -1005,8 +1008,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
if not preview:
return ""
- request = getattr(self, "request", None)
- config = getattr(request, "archivebox_config", None)
+ request = self.request
+ config = request.archivebox_config
favicon_url = build_snapshot_url(str(obj.id), "favicon/favicon.ico", request=request, config=config)
fallback_list = ",".join([build_snapshot_url(str(obj.id), "favicon.ico", request=request, config=config)])
onerror_js = (
@@ -1044,8 +1047,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
@admin.display(description=" ", empty_value="")
def snapshot_summary(self, obj):
- request = getattr(self, "request", None)
- config = getattr(request, "archivebox_config", None)
+ request = self.request
+ config = request.archivebox_config
preview = self._get_preview_data(obj)
stats = self._get_progress_stats(obj)
archive_size = stats["output_size"] or 0
@@ -1109,8 +1112,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
)
visible_results = sorted_results[:14]
output = []
- request = getattr(self, "request", None)
- config = getattr(request, "archivebox_config", None)
+ request = self.request
+ config = request.archivebox_config
for result in visible_results:
icon = mark_safe(get_plugin_icon(result.plugin))
if not icon.strip():
@@ -1141,8 +1144,8 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
# ordering='archiveresult_count'
)
def size(self, obj):
- request = getattr(self, "request", None)
- config = getattr(request, "archivebox_config", None)
+ request = self.request
+ config = request.archivebox_config
archive_size = self._get_progress_stats(obj)["output_size"] or 0
if archive_size:
size_txt = printable_filesize(archive_size)
@@ -1256,7 +1259,7 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
)
def _get_progress_stats(self, obj):
- cached_stats = getattr(obj, "_admin_progress_stats", None)
+ cached_stats = obj.__dict__.get("_admin_progress_stats")
if cached_stats is not None:
return cached_stats
@@ -1302,31 +1305,31 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
def _get_prefetched_results(self, obj):
if "_admin_archiveresults" in obj.__dict__:
return obj.__dict__["_admin_archiveresults"]
- if hasattr(obj, "_prefetched_objects_cache") and "archiveresult_set" in obj._prefetched_objects_cache:
+ if "archiveresult_set" in obj.__dict__.get("_prefetched_objects_cache", {}):
return obj.archiveresult_set.all()
return None
def _get_expected_hook_total(self, obj) -> int:
try:
- request = getattr(self, "request", None)
- if getattr(getattr(request, "resolver_match", None), "url_name", "") in {"core_snapshot_changelist", "core_snapshot_change"}:
+ request = self.request
+ if request.resolver_match.url_name in {"core_snapshot_changelist", "core_snapshot_change"}:
return 0
- crawl = getattr(obj, "crawl", None)
- snapshot_config = getattr(obj, "config", None) or {}
- crawl_config = getattr(crawl, "config", None) or {}
+ crawl = obj.crawl
+ snapshot_config = obj.config or {}
+ crawl_config = crawl.config or {}
has_scoped_config = bool(snapshot_config or crawl_config)
if request is not None and not has_scoped_config:
- cached_total = getattr(request, "archivebox_expected_snapshot_hook_total", None)
+ cached_total = request.__dict__.get("archivebox_expected_snapshot_hook_total")
if cached_total is None:
- config = getattr(request, "archivebox_config", None) or get_config()
+ config = request.archivebox_config
cached_total = len(discover_hooks("Snapshot", config=config))
request.archivebox_expected_snapshot_hook_total = cached_total
return cached_total
if request is not None:
- scoped_cache = getattr(request, "archivebox_expected_snapshot_hook_totals_by_scope", None)
+ scoped_cache = request.__dict__.get("archivebox_expected_snapshot_hook_totals_by_scope")
if scoped_cache is None:
scoped_cache = {}
request.archivebox_expected_snapshot_hook_totals_by_scope = scoped_cache
@@ -1346,8 +1349,9 @@ class SnapshotAdmin(SearchResultsAdminMixin, ConfigEditorMixin, BaseModelAdmin):
return 0
def _get_prefetched_tags(self, obj):
- if hasattr(obj, "_prefetched_objects_cache") and "tags" in obj._prefetched_objects_cache:
- return list(obj._prefetched_objects_cache["tags"])
+ prefetched_cache = obj.__dict__.get("_prefetched_objects_cache", {})
+ if "tags" in prefetched_cache:
+ return list(prefetched_cache["tags"])
return None
def _get_ordering_fields(self, request):
diff --git a/archivebox/core/admin_tags.py b/archivebox/core/admin_tags.py
index 07eb7209..db6c3dca 100644
--- a/archivebox/core/admin_tags.py
+++ b/archivebox/core/admin_tags.py
@@ -231,7 +231,7 @@ class TagAdmin(BaseModelAdmin):
@admin.display(description="Snapshots", ordering="num_snapshots")
def num_snapshots(self, tag: Tag):
- count = getattr(tag, "num_snapshots", None)
+ count = tag.__dict__.get("num_snapshots")
if count is None:
count = tag.snapshot_set.count()
return format_html(
diff --git a/archivebox/core/admin_users.py b/archivebox/core/admin_users.py
index bce9f6f1..d8e6ac3c 100644
--- a/archivebox/core/admin_users.py
+++ b/archivebox/core/admin_users.py
@@ -47,7 +47,7 @@ class CustomUserAdmin(UserAdmin):
def snapshot_count_badge(self, obj):
snapshots_url = f"/admin/core/snapshot/?created_by__id__exact={obj.pk}"
- snapshot_count = getattr(obj, "snapshot_count", 0)
+ snapshot_count = obj.__dict__.get("snapshot_count", 0)
snapshot_label = "snapshot" if snapshot_count == 1 else "snapshots"
return format_html(
(
diff --git a/archivebox/core/forms.py b/archivebox/core/forms.py
index 57fc280d..c47823be 100644
--- a/archivebox/core/forms.py
+++ b/archivebox/core/forms.py
@@ -31,6 +31,8 @@ DEPTH_CHOICES = (
class AddLinkForm(PluginConfigFormMixin, forms.Form):
+ allow_crawl_execution_config_fields = False
+
# Basic fields
url = forms.CharField(
label="URLs",
diff --git a/archivebox/core/middleware.py b/archivebox/core/middleware.py
index cfe7b770..50f02fd5 100644
--- a/archivebox/core/middleware.py
+++ b/archivebox/core/middleware.py
@@ -119,7 +119,7 @@ def CacheControlMiddleware(get_response):
if "/archive/" in request.path or "/static/" in request.path or snapshot_path_re.match(request.path):
if not response.get("Cache-Control"):
- config = getattr(request, "archivebox_config", None)
+ config = request.__dict__.get("archivebox_config")
if config is None:
config = get_config(resolve_plugins=False)
request.archivebox_config = config
@@ -136,7 +136,7 @@ def ServerSecurityModeMiddleware(get_response):
allowed_methods = {"GET", "HEAD", "OPTIONS"}
def middleware(request):
- config = getattr(request, "archivebox_config", None)
+ config = request.__dict__.get("archivebox_config")
if config is None:
config = get_config(resolve_plugins=False)
request.archivebox_config = config
@@ -169,7 +169,7 @@ def HostRoutingMiddleware(get_response):
return get_response(request)
request_host = (request.get_host() or "").lower()
- config = getattr(request, "archivebox_config", None)
+ config = request.__dict__.get("archivebox_config")
if config is None:
config = get_config(resolve_plugins=False)
request.archivebox_config = config
@@ -323,7 +323,7 @@ class ReverseProxyAuthMiddleware(RemoteUserMiddleware):
header = "HTTP_REMOTE_USER"
def process_request(self, request):
- config = getattr(request, "archivebox_config", None)
+ config = request.__dict__.get("archivebox_config")
if config is None:
config = get_config(resolve_plugins=False)
request.archivebox_config = config
diff --git a/archivebox/core/migrations/0048_remove_archiveresult_config.py b/archivebox/core/migrations/0048_remove_archiveresult_config.py
new file mode 100644
index 00000000..fe95e949
--- /dev/null
+++ b/archivebox/core/migrations/0048_remove_archiveresult_config.py
@@ -0,0 +1,14 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("core", "0047_archiveresult_status_snapshot_index"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="archiveresult",
+ name="config",
+ ),
+ ]
diff --git a/archivebox/core/models.py b/archivebox/core/models.py
index 33ee9e46..8a29eeb9 100755
--- a/archivebox/core/models.py
+++ b/archivebox/core/models.py
@@ -63,6 +63,13 @@ if TYPE_CHECKING:
from archivebox.config.common import ArchiveBoxBaseConfig
+class UngroupedSubquery(models.Subquery):
+ """Scalar subquery that should not be copied into the outer GROUP BY."""
+
+ def get_group_by_cols(self):
+ return []
+
+
class Tag(ModelWithUUID):
# Keep AutoField for compatibility with main branch migrations
# Don't use UUIDField here - requires complex FK transformation
@@ -203,7 +210,7 @@ class SnapshotQuerySet(models.QuerySet):
offset += chunk_size
return
- unique_field_names = {pk_field, *(field.name for field in self.model._meta.fields if getattr(field, "unique", False))}
+ unique_field_names = {pk_field, *(field.name for field in self.model._meta.fields if field.unique)}
if not any(field_name in unique_field_names for field_name in ordered_field_names):
offset = 0
while True:
@@ -659,7 +666,9 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
self.sm.seal()
def get_delete_after_config_value(self):
- return get_config(snapshot=self).DELETE_AFTER
+ from archivebox.config.common import resolve_delete_after_config_value
+
+ return resolve_delete_after_config_value(self.config, self.crawl.config)
@classmethod
def missing_delete_at_candidates(cls):
@@ -818,7 +827,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
# work and crawl maintenance so SQLite commits before touching the disk.
transaction.on_commit(finish_snapshot_save)
- migration_cleanup = getattr(self, "_pending_fs_migration_cleanup", None)
+ migration_cleanup = self.__dict__.get("_pending_fs_migration_cleanup")
if migration_cleanup:
old_dir, new_dir = migration_cleanup
transaction.on_commit(lambda: self._cleanup_old_migration_dir(old_dir, new_dir))
@@ -890,9 +899,9 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
runtime_config = config or get_config()
if source_dir and current == target:
- current_dir = self.get_storage_path_for_version(target, config=runtime_config)
+ current_dir = self.get_storage_path_for_version(target)
cleanup = self._fs_migrate_legacy_to_0_9_0(source_dir=source_dir, target_dir=current_dir)
- crawl_dir = self.crawl.output_dir_for_config(runtime_config)
+ crawl_dir = self.crawl.output_dir
old_crawl_dir = crawl_dir.with_name(str(uuid.UUID(hex=self.crawl.id.hex)))
if old_crawl_dir.exists() and not crawl_dir.exists() and not old_crawl_dir.is_symlink():
crawl_dir.parent.mkdir(parents=True, exist_ok=True)
@@ -932,9 +941,9 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
def _fs_migrate_from_0_9_0_to_0_9_4(self, source_dir: Path | None = None, config: "ArchiveBoxBaseConfig | None" = None):
runtime_config = config or get_config()
- target_dir = self.get_storage_path_for_version("0.9.4", config=runtime_config)
+ target_dir = self.get_storage_path_for_version("0.9.4")
cleanup = self._fs_migrate_legacy_to_0_9_0(source_dir=source_dir or self.output_dir, target_dir=target_dir, config=runtime_config)
- crawl_dir = self.crawl.output_dir_for_config(runtime_config)
+ crawl_dir = self.crawl.output_dir
old_crawl_dir = crawl_dir.with_name(str(uuid.UUID(hex=self.crawl.id.hex)))
if old_crawl_dir.exists() and not crawl_dir.exists() and not old_crawl_dir.is_symlink():
crawl_dir.parent.mkdir(parents=True, exist_ok=True)
@@ -956,8 +965,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
import filecmp
import shutil
- old_dir = Path(source_dir) if source_dir else self.get_storage_path_for_version("0.8.0", config=config)
- new_dir = Path(target_dir) if target_dir else self.get_storage_path_for_version("0.9.0", config=config)
+ old_dir = Path(source_dir) if source_dir else self.get_storage_path_for_version("0.8.0")
+ new_dir = Path(target_dir) if target_dir else self.get_storage_path_for_version("0.9.0")
if old_dir == new_dir:
return None
@@ -1087,7 +1096,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
except Exception:
return "unknown"
- def get_storage_path_for_version(self, version: str, config: "ArchiveBoxBaseConfig | None" = None) -> Path:
+ def get_storage_path_for_version(self, version: str) -> Path:
"""
Calculate storage path for specific filesystem version.
Centralizes path logic so it's reusable.
@@ -1095,10 +1104,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
0.7.x/0.8.x: archive/{timestamp}
0.9.x: archive/users/{username}/snapshots/YYYYMMDD/{domain}/{uuid}/
"""
- runtime_config = config or get_config()
-
if version in ("0.7.0", "0.8.0"):
- return runtime_config.ARCHIVE_DIR / self.timestamp
+ return CONSTANTS.ARCHIVE_DIR / self.timestamp
elif version in ("0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "1.0.0"):
username = self.created_by.username
@@ -1108,10 +1115,10 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
domain = self.extract_domain_from_url(self.url)
- return runtime_config.USERS_DIR / username / CONSTANTS.SNAPSHOTS_DIR_NAME / date_str / domain / str(self.id)
+ return CONSTANTS.USERS_DIR / username / CONSTANTS.SNAPSHOTS_DIR_NAME / date_str / domain / str(self.id)
else:
# Unknown version - use current
- return self.get_storage_path_for_version(self._fs_current_version(), config=runtime_config)
+ return self.get_storage_path_for_version(self._fs_current_version())
# =========================================================================
# Loading and Creation from Filesystem (Used by archivebox update ONLY)
@@ -1702,7 +1709,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
f.write(json.dumps(process.to_json()) + "\n")
# Write ArchiveResult record
- f.write(json.dumps(ar.to_json()) + "\n")
+ f.write(json.dumps(ar.to_json(snapshot_output_dir=output_dir)) + "\n")
os.replace(tmp_index_path, index_path)
def read_index_jsonl(self, output_dir: Path | None = None) -> dict:
@@ -1978,7 +1985,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
@admin.display(description="Tags")
def tags_str(self, nocache=True) -> str | None:
calc_tags_str = lambda: ",".join(sorted(tag.name for tag in self.tags.all()))
- prefetched_cache = getattr(self, "_prefetched_objects_cache", {})
+ prefetched_cache = self.__dict__.get("_prefetched_objects_cache", {})
if "tags" in prefetched_cache:
return calc_tags_str()
cache_key = f"{self.pk}-tags"
@@ -1988,12 +1995,12 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
"""Generate HTML icons showing which extractor plugins have succeeded for this snapshot"""
from django.utils.html import format_html
- compact_icons = getattr(self, "_icons_compact", False)
+ compact_icons = self.__dict__.get("_icons_compact", False)
cache_key = f"result_icons:{self.pk}:{'compact' if compact_icons else 'full'}:{(self.downloaded_at or self.modified_at or self.created_at or self.bookmarked_at).timestamp()}"
def calc_icons():
if compact_icons and self.status == self.StatusChoices.STARTED:
- progress_stats = getattr(self, "_icons_progress_stats", None) or self.get_progress_stats()
+ progress_stats = self.__dict__.get("_icons_progress_stats") or self.get_progress_stats()
total = int(progress_stats.get("total") or 0)
succeeded = int(progress_stats.get("succeeded") or 0)
failed = int(progress_stats.get("failed") or 0)
@@ -2025,8 +2032,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
running,
)
- precomputed_archive_results = getattr(self, "_icons_archive_results", None)
- prefetched_cache = getattr(self, "_prefetched_objects_cache", {})
+ precomputed_archive_results = self.__dict__.get("_icons_archive_results")
+ prefetched_cache = self.__dict__.get("_prefetched_objects_cache", {})
if precomputed_archive_results is not None and compact_icons:
archive_results = {plugin: True for plugin in precomputed_archive_results}
elif "archiveresult_set" in prefetched_cache:
@@ -2049,7 +2056,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
output_template = '{}'
# Get all plugins from hooks system (sorted by numeric prefix)
- all_plugins = getattr(self, "_icons_plugin_names", None)
+ all_plugins = self.__dict__.get("_icons_plugin_names")
if all_plugins is None and not compact_icons:
all_plugins = [get_plugin_name(e) for e in get_plugins()]
elif all_plugins is None:
@@ -2200,8 +2207,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
"""The filesystem path to the snapshot's output directory."""
import os
- runtime_config = getattr(self, "_runtime_config", None) or get_config()
- current_path = self.get_storage_path_for_version(self.fs_version, config=runtime_config)
+ current_path = self.get_storage_path_for_version(self.fs_version)
if current_path.exists():
return current_path
@@ -2212,7 +2218,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
return hyphen_path
# Check for backwards-compat symlink
- old_path = runtime_config.ARCHIVE_DIR / self.timestamp
+ old_path = CONSTANTS.ARCHIVE_DIR / self.timestamp
if old_path.is_symlink():
link_target = Path(os.readlink(old_path))
return (old_path.parent / link_target).resolve() if not link_target.is_absolute() else link_target.resolve()
@@ -2225,7 +2231,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
"""Ensure the legacy archive/ path resolves to this snapshot."""
import os
- legacy_path = get_config().ARCHIVE_DIR / self.timestamp
+ legacy_path = CONSTANTS.ARCHIVE_DIR / self.timestamp
target = Path(self.get_storage_path_for_version(self._fs_current_version()))
if target == legacy_path:
@@ -2301,8 +2307,8 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
if self.fs_version in ("0.9.0", "0.9.1", "0.9.2", "0.9.3", "0.9.4", "1.0.0"):
username = "web"
- crawl = getattr(self, "crawl", None)
- if crawl and getattr(crawl, "created_by_id", None):
+ crawl = self.crawl if self.crawl_id else None
+ if crawl and crawl.created_by_id:
username = crawl.created_by.username
if username == "system":
username = "web"
@@ -2326,7 +2332,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
output_dir = Path(self.output_dir).resolve()
try:
- rel_users_path = output_dir.relative_to(get_config().USERS_DIR)
+ rel_users_path = output_dir.relative_to(CONSTANTS.USERS_DIR)
except Exception:
rel_users_path = None
@@ -2836,7 +2842,7 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
@cached_property
def is_archived(self) -> bool:
- cached_is_archived = getattr(self, "_is_archived_cached", None)
+ cached_is_archived = self.__dict__.get("_is_archived_cached")
if cached_is_archived is not None:
return bool(cached_is_archived)
@@ -2890,10 +2896,10 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
@cached_property
def num_outputs(self) -> int:
- if hasattr(self, "num_outputs_cached"):
- return int(self.num_outputs_cached or 0)
+ if "num_outputs_cached" in self.__dict__:
+ return int(self.__dict__["num_outputs_cached"] or 0)
- prefetched_cache = getattr(self, "_prefetched_objects_cache", {})
+ prefetched_cache = self.__dict__.get("_prefetched_objects_cache", {})
if "archiveresult_set" in prefetched_cache:
return sum(1 for result in self.archiveresult_set.all() if result.status == "succeeded")
@@ -2901,10 +2907,10 @@ class Snapshot(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelW
@cached_property
def num_failures(self) -> int:
- if hasattr(self, "num_failures_cached"):
- return int(self.num_failures_cached or 0)
+ if "num_failures_cached" in self.__dict__:
+ return int(self.__dict__["num_failures_cached"] or 0)
- prefetched_cache = getattr(self, "_prefetched_objects_cache", {})
+ prefetched_cache = self.__dict__.get("_prefetched_objects_cache", {})
if "archiveresult_set" in prefetched_cache:
return sum(1 for result in self.archiveresult_set.all() if result.status == "failed")
@@ -3458,7 +3464,7 @@ class SnapshotMachine(BaseStateMachine):
# and the runner still needs to enqueue those child snapshots.
-class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWithNotes):
+class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithNotes):
class StatusChoices(models.TextChoices):
QUEUED = "queued", "Queued"
STARTED = "started", "Started"
@@ -3523,7 +3529,17 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
qs = cls.objects.filter(snapshot_id=models.OuterRef(outer_ref))
if status is not None:
qs = qs.filter(status=status)
- return qs.order_by().values("snapshot_id").annotate(count=models.Count("pk")).values("count")
+ return qs.order_by().values("snapshot_id").annotate(count=models.Count("*")).values("count")
+
+ @classmethod
+ def snapshot_half_count_subquery(cls, *, outer_ref: str = "snapshot_id") -> QuerySet:
+ return (
+ cls.objects.filter(snapshot_id=models.OuterRef(outer_ref))
+ .order_by()
+ .values("snapshot_id")
+ .annotate(half=models.Count("*") / models.Value(2))
+ .values("half")
+ )
@classmethod
def snapshot_count_expr(cls, *, status: str | None = None, outer_ref: str = "pk"):
@@ -3539,28 +3555,50 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
return {status: qs.filter(status=status).count() for status in (statuses or cls.StatusChoices.values)}
@classmethod
- def snapshot_ids_with_majority_status(cls, status: str) -> QuerySet:
+ def snapshot_ids_with_majority_status(cls, status: str | Iterable[str]) -> QuerySet:
"""Return Snapshot IDs where more than half of ArchiveResults have ``status``.
Start from ArchiveResult.status for every majority-status filter. The
``(status, snapshot_id)`` index keeps the plan predictable even when a
user's collection has an unusual status distribution.
"""
+ statuses = tuple(status) if not isinstance(status, str) else (status,)
+ total_half = UngroupedSubquery(cls.snapshot_half_count_subquery(outer_ref="snapshot_id"), output_field=models.IntegerField())
return (
- cls.objects.filter(status=status)
+ cls.objects.filter(status__in=statuses)
.order_by()
.values("snapshot_id")
.annotate(
- matching_results=models.Count("pk"),
- total_results=models.Subquery(
- cls.snapshot_count_subquery(outer_ref="snapshot_id"),
- output_field=models.IntegerField(),
- ),
+ matching_results=models.Count("*"),
+ total_half=total_half,
)
- .filter(matching_results__gt=models.F("total_results") / 2)
+ .filter(matching_results__gt=models.F("total_half"))
.values("snapshot_id")
)
+ @classmethod
+ def cached_snapshot_ids_with_majority_status(cls, status: str | Iterable[str], *, timeout: int = 60) -> tuple[str, ...]:
+ statuses = tuple(status) if not isinstance(status, str) else (status,)
+ cache_key = f"archivebox:archiveresult:majority_status:{':'.join(sorted(statuses))}"
+ cached_ids = cache.get(cache_key)
+ if cached_ids is not None:
+ return tuple(cached_ids)
+
+ snapshot_ids = tuple(
+ str(snapshot_id) for snapshot_id in cls.snapshot_ids_with_majority_status(statuses).values_list("snapshot_id", flat=True)
+ )
+ cache.set(cache_key, snapshot_ids, timeout=timeout)
+ return snapshot_ids
+
+ @classmethod
+ def clear_majority_status_cache(cls) -> None:
+ cache.delete_many(
+ [
+ *(f"archivebox:archiveresult:majority_status:{status}" for status in cls.StatusChoices.values),
+ f"archivebox:archiveresult:majority_status:{':'.join(sorted((cls.StatusChoices.BACKOFF, cls.StatusChoices.QUEUED)))}",
+ ],
+ )
+
# UUID primary key (migrated from integer in 0029)
id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True)
created_at = models.DateTimeField(default=timezone.now, db_index=True)
@@ -3609,7 +3647,6 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
class Meta(
ModelWithDeleteAfter.Meta,
ModelWithOutputDir.Meta,
- ModelWithConfig.Meta,
ModelWithNotes.Meta,
):
app_label = "core"
@@ -3644,14 +3681,15 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
return "\n".join(self._format_output_line_for_display(line) for line in str(self.output_str or "").splitlines())
def get_delete_after_config_value(self):
- return get_config(archiveresult=self).DELETE_AFTER
+ snapshot = self.snapshot
+ from archivebox.config.common import resolve_delete_after_config_value
+
+ return resolve_delete_after_config_value(snapshot.config, snapshot.crawl.config)
@classmethod
def missing_delete_at_candidates(cls):
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__config__has_key="DELETE_AFTER") | Q(snapshot__crawl__config__has_key="DELETE_AFTER"),
)
@property
@@ -3659,12 +3697,21 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
"""Convenience property to access the user who created this archive result via its snapshot's crawl."""
return self.snapshot.crawl.created_by
- def to_json(self) -> dict:
+ def to_json(self, *, snapshot_output_dir: Path | None = None) -> dict:
"""
Convert ArchiveResult model instance to a JSON-serializable dict.
"""
from archivebox.config import VERSION
+ process = self.process_record
+ pwd = (
+ process.pwd
+ if process and process.pwd
+ else str((snapshot_output_dir / self.plugin) if snapshot_output_dir is not None else self.output_dir)
+ )
+ cmd = process.cmd if process else []
+ cmd_version = process.cmd_version if process else ""
+
record = {
"type": "ArchiveResult",
"schema_version": VERSION,
@@ -3686,13 +3733,12 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
record["output_size"] = self.output_size
if self.output_mimetypes:
record["output_mimetypes"] = self.output_mimetypes
- if self.pwd:
- record["pwd"] = self.pwd
- if self.cmd:
- record["cmd"] = self.cmd
- if self.cmd_version:
- record["cmd_version"] = self.cmd_version
- process = self.process_record
+ if pwd:
+ record["pwd"] = pwd
+ if cmd:
+ record["cmd"] = cmd
+ if cmd_version:
+ record["cmd_version"] = cmd_version
if process:
record["process_id"] = str(process.id)
return record
@@ -3767,6 +3813,8 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
if refresh_snapshot_size:
snapshot_ids = {snapshot_id for snapshot_id in (old_snapshot_id, self.snapshot_id) if snapshot_id}
transaction.on_commit(lambda: type(self).refresh_snapshot_output_sizes(snapshot_ids))
+ if is_new or update_fields is None or "status" in update_fields or "snapshot" in update_fields or "snapshot_id" in update_fields:
+ transaction.on_commit(type(self).clear_majority_status_cache)
# if is_new:
# from archivebox.misc.logging_util import log_worker_event
@@ -3788,6 +3836,7 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
deleted = super().delete(*args, **kwargs)
if snapshot_id:
transaction.on_commit(lambda: type(self).refresh_snapshot_output_sizes({snapshot_id}))
+ transaction.on_commit(type(self).clear_majority_status_cache)
return deleted
@staticmethod
@@ -4495,7 +4544,8 @@ class ArchiveResult(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, M
def _url_passes_filters(self, url: str) -> bool:
"""Check if URL passes URL_ALLOWLIST and URL_DENYLIST config filters.
- Uses proper config hierarchy: defaults -> file -> env -> machine -> user -> crawl -> snapshot
+ Uses the centralized config resolver so frozen crawl/snapshot values
+ and live Machine/Persona execution values apply in their scoped order.
"""
return self.snapshot.crawl.url_passes_filters(url, snapshot=self.snapshot)
diff --git a/archivebox/core/settings.py b/archivebox/core/settings.py
index 0e97b412..4d3f559f 100644
--- a/archivebox/core/settings.py
+++ b/archivebox/core/settings.py
@@ -174,9 +174,9 @@ except ImportError:
STATIC_URL = "/static/"
TEMPLATES_DIR_NAME = "templates"
-CUSTOM_TEMPLATES_ENABLED = os.path.isdir(CONFIG.CUSTOM_TEMPLATES_DIR) and os.access(CONFIG.CUSTOM_TEMPLATES_DIR, os.R_OK)
+CUSTOM_TEMPLATES_ENABLED = os.path.isdir(CONSTANTS.CUSTOM_TEMPLATES_DIR) and os.access(CONSTANTS.CUSTOM_TEMPLATES_DIR, os.R_OK)
STATICFILES_DIRS = [
- *([str(CONFIG.CUSTOM_TEMPLATES_DIR / "static")] if CUSTOM_TEMPLATES_ENABLED else []),
+ *([str(CONSTANTS.CUSTOM_TEMPLATES_DIR / "static")] if CUSTOM_TEMPLATES_ENABLED else []),
# *[
# str(plugin_dir / 'static')
# for plugin_dir in PLUGIN_DIRS.values()
@@ -187,7 +187,7 @@ STATICFILES_DIRS = [
]
TEMPLATE_DIRS = [
- *([str(CONFIG.CUSTOM_TEMPLATES_DIR)] if CUSTOM_TEMPLATES_ENABLED else []),
+ *([str(CONSTANTS.CUSTOM_TEMPLATES_DIR)] if CUSTOM_TEMPLATES_ENABLED else []),
# *[
# str(plugin_dir / 'templates')
# for plugin_dir in PLUGIN_DIRS.values()
@@ -334,7 +334,7 @@ STORAGES = {
"BACKEND": "django.core.files.storage.FileSystemStorage",
"OPTIONS": {
"base_url": "/archive/",
- "location": CONFIG.ARCHIVE_DIR,
+ "location": CONSTANTS.ARCHIVE_DIR,
},
},
# "snapshots": {
diff --git a/archivebox/core/shutdown_util.py b/archivebox/core/shutdown_util.py
index 95bb7edf..f9273360 100644
--- a/archivebox/core/shutdown_util.py
+++ b/archivebox/core/shutdown_util.py
@@ -134,7 +134,10 @@ def foreground_shutdown_signals(
# signal for hard foreground-command shutdown. Server/update/run and
# other non-interactive commands raise immediately so their finally
# blocks can stop owned children without prompting.
- if raise_on_first_signal or already_requested:
+ if already_requested:
+ os.write(sys.stdout.fileno(), f"\n[🛑] Got {sig.name} again, exiting immediately.\n".encode())
+ os._exit(130)
+ if raise_on_first_signal:
raise KeyboardInterrupt
try:
@@ -143,6 +146,8 @@ def foreground_shutdown_signals(
signal.signal(sig, raise_keyboard_interrupt)
yield state
finally:
+ if state.signal_name and previous_active_state is not None and not previous_active_state.signal_name:
+ previous_active_state.signal_name = state.signal_name
_active_shutdown_state = previous_active_state
for sig, previous_handler in previous_handlers.items():
signal.signal(sig, previous_handler)
diff --git a/archivebox/core/tag_util.py b/archivebox/core/tag_util.py
index d970cb09..27289c05 100644
--- a/archivebox/core/tag_util.py
+++ b/archivebox/core/tag_util.py
@@ -246,7 +246,7 @@ def _build_snapshot_preview_map(
def build_tag_card(tag: Tag, snapshot_previews: list[dict[str, Any]] | None = None) -> dict[str, Any]:
- count = getattr(tag, "num_snapshots", None)
+ count = tag.__dict__.get("num_snapshots")
if count is None:
count = tag.snapshot_set.count()
return {
diff --git a/archivebox/core/templatetags/core_tags.py b/archivebox/core/templatetags/core_tags.py
index 1ff044ee..dce6857d 100644
--- a/archivebox/core/templatetags/core_tags.py
+++ b/archivebox/core/templatetags/core_tags.py
@@ -76,6 +76,12 @@ def _normalize_output_files(output_files: Any) -> dict[str, dict[str, Any]]:
return {}
+def _snapshot_id(value: Any) -> Any:
+ from archivebox.core.models import Snapshot
+
+ return value.id if isinstance(value, Snapshot) else value
+
+
def _coerce_output_file_size(value: Any) -> int | None:
try:
return max(int(value or 0), 0)
@@ -85,7 +91,7 @@ def _coerce_output_file_size(value: Any) -> int | None:
def _count_media_files(result) -> int:
try:
- output_files = _normalize_output_files(getattr(result, "output_files", None) or {})
+ output_files = _normalize_output_files(result.output_files or {})
except Exception:
output_files = {}
@@ -121,7 +127,7 @@ def _list_media_files(result) -> list[dict]:
except Exception:
return media_files
- output_files = _normalize_output_files(getattr(result, "output_files", None) or {})
+ output_files = _normalize_output_files(result.output_files or {})
candidates: list[tuple[Path, int | None]] = []
if output_files:
for path, metadata in output_files.items():
@@ -300,7 +306,7 @@ def result_list(context, cl):
"""
num_sorted_fields = 0
request = context.get("request")
- config = getattr(request, "archivebox_config", None) or context.get("CONFIG")
+ config = request.__dict__.get("archivebox_config") if request is not None else context.get("CONFIG")
results = cl.result_list
if config is not None:
for obj in results:
@@ -443,7 +449,7 @@ def _unconfigured_banner_context(request) -> dict:
# operator's clipboard already aligned with subdomain routing. The config
# parser strips the leading ``*.`` so users can paste it verbatim.
suggested_base_url = f"{scheme}://*.{canonical_host}" if canonical_host else ""
- user = getattr(request, "user", None)
+ user = request.user
is_superuser = bool(user and user.is_authenticated and user.is_superuser)
machine_admin_url = ""
if is_superuser:
@@ -496,19 +502,19 @@ def public_base_url(context) -> str:
@register.simple_tag(takes_context=True)
def snapshot_base_url(context, snapshot) -> str:
- snapshot_id = getattr(snapshot, "id", snapshot)
+ snapshot_id = _snapshot_id(snapshot)
return get_snapshot_base_url(str(snapshot_id), request=context.get("request"), config=context.get("CONFIG"))
@register.simple_tag(takes_context=True)
def snapshot_url(context, snapshot, path: str = "") -> str:
- snapshot_id = getattr(snapshot, "id", snapshot)
+ snapshot_id = _snapshot_id(snapshot)
return build_snapshot_url(str(snapshot_id), path, request=context.get("request"), config=context.get("CONFIG"))
@register.simple_tag(takes_context=True)
def snapshot_preview_url(context, snapshot, path: str = "") -> str:
- snapshot_id = getattr(snapshot, "id", snapshot)
+ snapshot_id = _snapshot_id(snapshot)
return _build_snapshot_preview_url(str(snapshot_id), path, request=context.get("request"), config=context.get("CONFIG"))
@@ -538,16 +544,18 @@ def plugin_card(context, result) -> str:
- output_path: Path to output relative to snapshot dir (from embed_path())
- plugin: Plugin base name
"""
- if result is None or not hasattr(result, "plugin"):
+ from archivebox.core.models import ArchiveResult
+
+ if result is None or not isinstance(result, ArchiveResult):
return ""
plugin = get_plugin_name(result.plugin)
template_str = get_plugin_template(plugin, "card")
# Use embed_path() for the display path
- raw_output_path = result.embed_path() if hasattr(result, "embed_path") else ""
+ raw_output_path = result.embed_path() or ""
output_url = build_snapshot_url(
- str(getattr(result, "snapshot_id", "")),
+ str(result.snapshot_id),
raw_output_path or "",
request=context.get("request"),
config=context.get("CONFIG"),
@@ -558,7 +566,7 @@ def plugin_card(context, result) -> str:
media_file_count = _count_media_files(result) if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl") else 0
media_files = _list_media_files(result) if plugin_lower in ("ytdlp", "yt-dlp", "youtube-dl") else []
if media_files:
- snapshot_id = str(getattr(result, "snapshot_id", ""))
+ snapshot_id = str(result.snapshot_id)
request = context.get("request")
config = context.get("CONFIG")
for item in media_files:
@@ -592,7 +600,7 @@ def plugin_card(context, result) -> str:
pass
if force_text_preview:
- preview = _render_text_file_preview(getattr(result, "snapshot_dir", None), raw_output_path, plugin, icon_html)
+ preview = _render_text_file_preview(result.snapshot_dir, raw_output_path, plugin, icon_html)
if preview:
return mark_safe(preview)
@@ -608,7 +616,7 @@ def plugin_card(context, result) -> str:
def output_card(snapshot, output_path: str, plugin: str) -> str:
plugin_name = get_plugin_name(plugin)
icon_html = get_plugin_icon(plugin_name)
- preview = _render_text_file_preview(getattr(snapshot, "output_dir", None), output_path, plugin_name, icon_html)
+ preview = _render_text_file_preview(snapshot.output_dir, output_path, plugin_name, icon_html)
if preview:
return mark_safe(preview)
@@ -624,7 +632,9 @@ def plugin_full(context, result) -> str:
Usage: {% plugin_full result %}
"""
- if result is None or not hasattr(result, "plugin"):
+ from archivebox.core.models import ArchiveResult
+
+ if result is None or not isinstance(result, ArchiveResult):
return ""
plugin = get_plugin_name(result.plugin)
@@ -634,14 +644,13 @@ def plugin_full(context, result) -> str:
return ""
raw_output_path = ""
- if hasattr(result, "embed_path_db"):
- raw_output_path = result.embed_path_db() or ""
- if not raw_output_path and hasattr(result, "embed_path"):
+ raw_output_path = result.embed_path_db() or ""
+ if not raw_output_path:
raw_output_path = result.embed_path() or ""
if _is_root_snapshot_output_path(raw_output_path):
return ""
output_url = build_snapshot_url(
- str(getattr(result, "snapshot_id", "")),
+ str(result.snapshot_id),
raw_output_path,
request=context.get("request"),
config=context.get("CONFIG"),
@@ -685,7 +694,7 @@ def api_token(context) -> str:
from archivebox.api.auth import get_or_create_api_token
request = context.get("request")
- user = getattr(request, "user", None)
+ user = request.user
if not user or not user.is_authenticated:
return ""
diff --git a/archivebox/core/views.py b/archivebox/core/views.py
index 94a5b29e..6790c87f 100644
--- a/archivebox/core/views.py
+++ b/archivebox/core/views.py
@@ -5,9 +5,7 @@ import os
import posixpath
from glob import glob, escape
from django.utils import timezone
-import inspect
from typing import cast
-from collections.abc import Callable
from pathlib import Path
from urllib.parse import quote, urlparse
@@ -30,7 +28,17 @@ from admin_data_views.utils import render_with_table_view, render_with_item_view
from abx_plugins.plugins.archivewebpage import replay_preview as archivewebpage_replay
from archivebox.config import CONSTANTS, CONSTANTS_CONFIG, VERSION
-from archivebox.config.common import SENSITIVE_CONFIG_VALUE_REDACTED, get_config, get_all_configs, redact_sensitive_config
+from archivebox.config.common import (
+ SENSITIVE_CONFIG_VALUE_REDACTED,
+ find_config_default,
+ find_config_section,
+ find_config_source,
+ find_config_type,
+ get_config,
+ get_all_configs,
+ get_request_config,
+ redact_sensitive_config,
+)
from archivebox.config.configset import BaseConfigSet
from archivebox.misc.paginators import CountlessPaginator
from archivebox.misc.util import (
@@ -80,16 +88,6 @@ from archivebox.plugins.views import get_config_definition_link
from archivebox.progressmonitor.views import live_progress_view, progress_endpoint
-def _get_request_config(request: HttpRequest, *, resolve_plugins: bool = False):
- request_config = getattr(request, "archivebox_config", None)
- request_config_resolves_plugins = bool(getattr(request, "_archivebox_config_resolves_plugins", False))
- if request_config is None or (resolve_plugins and not request_config_resolves_plugins):
- request_config = get_config(resolve_plugins=resolve_plugins)
- request.archivebox_config = request_config
- request._archivebox_config_resolves_plugins = resolve_plugins
- return request_config
-
-
def _files_index_target(snapshot: Snapshot, archivefile: str | None) -> str:
target = archivefile or ""
if target == "index.html":
@@ -122,14 +120,14 @@ def _find_snapshot_by_ref(snapshot_ref: str) -> Snapshot | None:
def _admin_login_redirect_or_forbidden(request: HttpRequest):
- if _get_request_config(request).CONTROL_PLANE_ENABLED:
+ if get_request_config(request).CONTROL_PLANE_ENABLED:
return redirect(f"/admin/login/?next={request.path}")
return HttpResponseForbidden("ArchiveBox is running with the control plane disabled in this security mode.")
class HomepageView(View):
def get(self, request):
- request_config = _get_request_config(request)
+ request_config = get_request_config(request)
if request.user.is_authenticated and request_config.CONTROL_PLANE_ENABLED:
return redirect("/admin/core/snapshot/")
@@ -200,7 +198,7 @@ class SnapshotView(View):
# Reuse the middleware-attached config; never re-bootstrap from env + plugin
# schemas just to render a snapshot page (that pays ~30ms for no reason).
- runtime_config = _get_request_config(request)
+ runtime_config = get_request_config(request)
snapshot._runtime_config = runtime_config
snapshot_permissions = get_snapshot_permissions(snapshot)
hidden_card_plugins = {"archivedotorg", "favicon", "title"}
@@ -734,7 +732,7 @@ def _replay_path_visible(request: HttpRequest, path: Path) -> bool:
snapshot = Snapshot.objects.filter(id=snapshot_id).select_related("crawl", "crawl__created_by").first()
if not snapshot or not can_view_snapshot(request, snapshot):
return False
- request.archivebox_config = _get_request_config(request, resolve_plugins=False)
+ request.archivebox_config = get_request_config(request, resolve_plugins=False)
return True
@@ -845,11 +843,14 @@ def _serve_responses_path(request, responses_root: Path, rel_path: str, show_ind
def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str = ""):
- request_config = _get_request_config(request, resolve_plugins=False)
+ rel_path = path or ""
+ request_config = get_request_config(
+ request,
+ resolve_plugins=rel_path.startswith("replay/") or rel_path == "replay",
+ )
request.archivebox_config = request_config
request.archivebox_snapshot_url = snapshot.url
snapshot._runtime_config = request_config
- rel_path = path or ""
if rel_path.startswith("replay/") or rel_path == "replay":
response = archivewebpage_replay.serve_replay_asset_response(rel_path, request_config, HttpResponse)
@@ -899,7 +900,8 @@ def _serve_snapshot_replay(request: HttpRequest, snapshot: Snapshot, path: str =
def _serve_original_domain_replay(request: HttpRequest, domain: str, path: str = ""):
- request_config = _get_request_config(request)
+ request_config = get_request_config(request, resolve_plugins=False)
+ request.archivebox_config = request_config
requested_root_index = path in ("", "index.html") or path.endswith("/")
rel_path = path or ""
if not rel_path or rel_path.endswith("/"):
@@ -909,13 +911,13 @@ def _serve_original_domain_replay(request: HttpRequest, domain: str, path: str =
raise Http404
domain = domain.lower()
- match = _latest_response_match(request, domain, rel_path, data_root=request_config.USERS_DIR)
+ match = _latest_response_match(request, domain, rel_path, data_root=CONSTANTS.USERS_DIR)
if not match and "." not in Path(rel_path).name:
index_path = f"{rel_path.rstrip('/')}/index.html"
- match = _latest_response_match(request, domain, index_path, data_root=request_config.USERS_DIR)
+ match = _latest_response_match(request, domain, index_path, data_root=CONSTANTS.USERS_DIR)
if not match and "." not in Path(rel_path).name:
html_path = f"{rel_path}.html"
- match = _latest_response_match(request, domain, html_path, data_root=request_config.USERS_DIR)
+ match = _latest_response_match(request, domain, html_path, data_root=CONSTANTS.USERS_DIR)
show_indexes = bool(request.GET.get("files"))
if match:
@@ -924,7 +926,7 @@ def _serve_original_domain_replay(request: HttpRequest, domain: str, path: str =
if response is not None:
return response
- responses_root = _latest_responses_root(request, domain, data_root=request_config.USERS_DIR)
+ responses_root = _latest_responses_root(request, domain, data_root=CONSTANTS.USERS_DIR)
if responses_root:
response = _serve_responses_path(request, responses_root, rel_path, show_indexes)
if response is not None:
@@ -946,7 +948,7 @@ class SnapshotHostView(View):
"""Serve snapshot directory contents on ./."""
def get(self, request, snapshot_id: str, path: str = ""):
- request_config = _get_request_config(request)
+ request_config = get_request_config(request)
snapshot = _find_snapshot_by_ref(snapshot_id)
if not snapshot:
@@ -998,15 +1000,15 @@ class PublicIndexView(ListView):
paginator_class = CountlessPaginator
def get_paginate_by(self, queryset):
- runtime_config = getattr(self, "runtime_config", None)
+ runtime_config = self.__dict__.get("runtime_config")
if runtime_config is None:
- self.runtime_config = runtime_config = _get_request_config(self.request, resolve_plugins=False)
+ self.runtime_config = runtime_config = get_request_config(self.request, resolve_plugins=False)
return runtime_config.SNAPSHOTS_PER_PAGE
def get_context_data(self, **kwargs):
- runtime_config = getattr(self, "runtime_config", None)
+ runtime_config = self.__dict__.get("runtime_config")
if runtime_config is None:
- self.runtime_config = runtime_config = _get_request_config(self.request, resolve_plugins=False)
+ self.runtime_config = runtime_config = get_request_config(self.request, resolve_plugins=False)
search_mode = get_search_mode(self.request.GET.get("search_mode"), config=runtime_config)
search_mode_backend = get_search_mode_backend(search_mode, config=runtime_config)
context = {
@@ -1023,7 +1025,7 @@ class PublicIndexView(ListView):
self.request.GET.get("q")
and get_search_mode_base(search_mode, config=runtime_config) == "deep"
and search_mode_backend
- and getattr(context.get("paginator"), "count", 0) == 0,
+ and context["paginator"].count == 0,
)
snapshots = list(context.get("object_list") or ())
icons_by_snapshot: dict[str, set[str]] = {str(snapshot.id): set() for snapshot in snapshots}
@@ -1086,7 +1088,7 @@ class PublicIndexView(ListView):
if not query:
return qs
- runtime_config = getattr(self, "runtime_config", None)
+ runtime_config = self.__dict__.get("runtime_config")
search_mode = get_search_mode(self.request.GET.get("search_mode"), config=runtime_config)
try:
return apply_snapshot_search(
@@ -1105,7 +1107,7 @@ class PublicIndexView(ListView):
def get(self, *args, **kwargs):
if self.request.user.is_authenticated:
return redirect("/admin/core/snapshot/")
- if _get_request_config(self.request).PUBLIC_INDEX:
+ if get_request_config(self.request).PUBLIC_INDEX:
response = super().get(*args, **kwargs)
return response
else:
@@ -1132,7 +1134,7 @@ class AddView(UserPassesTestMixin, FormView):
return kwargs
def test_func(self):
- return _get_request_config(self.request).PUBLIC_ADD_VIEW or self.request.user.is_authenticated
+ return get_request_config(self.request).PUBLIC_ADD_VIEW or self.request.user.is_authenticated
def _can_override_crawl_config(self) -> bool:
return is_admin_user(self.request)
@@ -1146,11 +1148,11 @@ class AddView(UserPassesTestMixin, FormView):
if not self._can_override_crawl_config():
return {}
- return custom_config
+ return {str(key): value for key, value in custom_config.items() if not str(key).endswith("_BINARY")}
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
- request_config = _get_request_config(self.request, resolve_plugins=True)
+ request_config = get_request_config(self.request, resolve_plugins=True)
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 {}
@@ -1252,7 +1254,7 @@ class AddView(UserPassesTestMixin, FormView):
created_by_id = get_or_create_system_user_pk()
- created_by_name = getattr(self.request.user, "username", "web") if self.request.user.is_authenticated else "web"
+ created_by_name = self.request.user.username if self.request.user.is_authenticated else "web"
# 1. save the provided urls to sources/2024-11-05__23-59-59__web_ui_add_by_user_.txt
sources_file = CONSTANTS.SOURCES_DIR / f"{timezone.now().strftime('%Y-%m-%d__%H-%M-%S')}__web_ui_add_by_user_{created_by_id}.txt"
@@ -1267,8 +1269,7 @@ class AddView(UserPassesTestMixin, FormView):
config = {}
if plugins:
config["PLUGINS"] = plugins
- 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)
+ effective_config = get_config(persona=persona) if persona else get_config()
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):
@@ -1378,11 +1379,7 @@ class WebAddView(AddView):
return redirect(f"/{snapshot.url_path}")
request_host = (request.get_host() or "").lower()
- if (
- request.user.is_authenticated
- and not _get_request_config(request).PUBLIC_ADD_VIEW
- and host_matches(request_host, get_web_host())
- ):
+ if request.user.is_authenticated and not get_request_config(request).PUBLIC_ADD_VIEW and host_matches(request_host, get_web_host()):
return redirect(build_admin_url(request.get_full_path(), request=request))
if not self.test_func():
@@ -1461,79 +1458,11 @@ class HealthCheckView(View):
return HttpResponse("OK", content_type="text/plain", status=200)
-def find_config_section(key: str) -> str:
- CONFIGS = get_all_configs()
-
- if key in CONSTANTS_CONFIG:
- return "CONSTANT"
- matching_sections = [section_id for section_id, section in CONFIGS.items() if key in dict(section)]
- section = matching_sections[0] if matching_sections else "DYNAMIC"
- return section
-
-
-def find_config_default(key: str) -> str:
- CONFIGS = get_all_configs()
-
- if key in CONSTANTS_CONFIG:
- return str(CONSTANTS_CONFIG[key])
-
- default_val = None
-
- for config in CONFIGS.values():
- if key in dict(config):
- default_val = type(config).model_fields[key].default
- break
-
- if isinstance(default_val, Callable):
- default_val = inspect.getsource(default_val).split("lambda", 1)[-1].split(":", 1)[-1].replace("\n", " ").strip()
- if default_val.count(")") > default_val.count("("):
- default_val = default_val[:-1]
- else:
- default_val = str(default_val)
-
- return default_val
-
-
-def find_config_type(key: str) -> str:
- CONFIGS = get_all_configs()
-
- for config in CONFIGS.values():
- if key in type(config).model_fields:
- annotation = type(config).model_fields[key].annotation
- return getattr(annotation, "__name__", str(annotation))
- return "str"
-
-
-def find_config_source(key: str, merged_config: dict) -> str:
- """Determine where a config value comes from."""
- from archivebox.machine.models import Machine
-
- # Environment variables override all persistent config sources.
- if key in os.environ:
- return "Environment"
-
- # Machine.config overrides ArchiveBox.conf.
- try:
- machine = Machine.current()
- if machine.config and key in machine.config:
- return "Machine"
- except Exception:
- pass
-
- # Check if it's from archivebox.config.file
- file_config = BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE)
- if key in file_config:
- return "Config File"
-
- # Otherwise it's using the default
- return "Default"
-
-
@render_with_table_view
def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
CONFIGS = get_all_configs()
- assert getattr(request.user, "is_superuser", False), "Must be a superuser to view configuration settings."
+ assert request.user.is_superuser, "Must be a superuser to view configuration settings."
merged_config = get_config(redact_sensitive=True)
@@ -1555,12 +1484,12 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
rows["Type"].append(format_html("{}", find_config_type(key)))
# Use merged config value (includes machine overrides)
- actual_value = merged_config.get(key, getattr(section, key, None))
+ actual_value = merged_config.get(key, dict(section)[key])
rows["Value"].append(mark_safe(f"{actual_value}"))
# Show where the value comes from
source = find_config_source(key, merged_config)
- source_colors = {"Machine": "purple", "Environment": "blue", "Config File": "green", "Default": "gray"}
+ source_colors = {"Machine": "purple", "Environment": "blue", "File": "green", "Plugin Default": "teal", "Default": "gray"}
rows["Source"].append(format_html('{}', source_colors.get(source, "gray"), source))
rows["Default"].append(
@@ -1575,7 +1504,7 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
for key in CONSTANTS_CONFIG.keys():
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["Type"].append(format_html("{}", type(CONSTANTS_CONFIG[key]).__name__))
rows["Value"].append(format_html("{}", redact_sensitive_config(CONSTANTS_CONFIG).get(key)))
rows["Source"].append(mark_safe('Constant'))
rows["Default"].append(
@@ -1598,17 +1527,13 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
CONFIGS = get_all_configs()
- assert getattr(request.user, "is_superuser", False), "Must be a superuser to view configuration settings."
+ assert request.user.is_superuser, "Must be a superuser to view configuration settings."
merged_config = get_config(redact_sensitive=True)
# Determine all sources for this config value
sources_info = []
- # Environment variable
- if key in os.environ:
- sources_info.append(("Environment", redact_sensitive_config(os.environ).get(key), "blue"))
-
# Machine config
machine = None
machine_admin_url = None
@@ -1620,11 +1545,15 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
except Exception:
pass
+ # Environment variable
+ if key in os.environ:
+ sources_info.append(("Environment", redact_sensitive_config(os.environ).get(key), "blue"))
+
# Config file value
if CONSTANTS.CONFIG_FILE.exists():
file_config = BaseConfigSet.load_from_file(CONSTANTS.CONFIG_FILE)
if key in file_config:
- sources_info.append(("Config File", redact_sensitive_config(file_config).get(key), "green"))
+ sources_info.append(("File", redact_sensitive_config(file_config).get(key), "green"))
# Default value
default_val = find_config_default(key)
diff --git a/archivebox/core/widgets.py b/archivebox/core/widgets.py
index 3c4d16ee..c4c60047 100644
--- a/archivebox/core/widgets.py
+++ b/archivebox/core/widgets.py
@@ -4,6 +4,8 @@ import json
import re
import hashlib
from django import forms
+from django.db.models.manager import BaseManager
+from django.db.models.query import QuerySet
from django.utils.html import escape
from django.utils.safestring import mark_safe
@@ -65,10 +67,12 @@ class TagEditorWidget(forms.Widget):
# Parse value to get list of tag names
tags = []
if value:
- if hasattr(value, "all"): # QuerySet
+ if isinstance(value, (BaseManager, QuerySet)):
tags = sorted([tag.name for tag in value.all()])
elif isinstance(value, (list, tuple)):
- if value and hasattr(value[0], "name"): # List of Tag objects
+ from archivebox.core.models import Tag
+
+ if value and isinstance(value[0], Tag): # List of Tag objects
tags = sorted([tag.name for tag in value])
else: # List of strings or IDs
# Could be tag IDs from form submission
@@ -675,12 +679,14 @@ class InlineTagEditorWidget(TagEditorWidget):
# Parse value to get list of tag dicts with id and name
tag_data = []
if value:
- if hasattr(value, "all"): # QuerySet
+ if isinstance(value, (BaseManager, QuerySet)):
for tag in value.all():
tag_data.append({"id": tag.pk, "name": tag.name})
tag_data.sort(key=lambda x: x["name"].lower())
elif isinstance(value, (list, tuple)):
- if value and hasattr(value[0], "name"):
+ from archivebox.core.models import Tag
+
+ if value and isinstance(value[0], Tag):
for tag in value:
tag_data.append({"id": tag.pk, "name": tag.name})
tag_data.sort(key=lambda x: x["name"].lower())
diff --git a/archivebox/crawls/admin.py b/archivebox/crawls/admin.py
index b0d04b32..37411e88 100644
--- a/archivebox/crawls/admin.py
+++ b/archivebox/crawls/admin.py
@@ -605,9 +605,7 @@ class CrawlAdminForm(forms.ModelForm):
if commit:
instance.save()
instance.apply_crawl_config_filters()
- save_m2m = getattr(self, "_save_m2m", None)
- if callable(save_m2m):
- save_m2m()
+ self._save_m2m()
return instance
@@ -729,7 +727,6 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
super().__init__(model, admin_site)
self.crawl_admin_base_config = None
self.stop_reason_cache = {}
- self.persona_limit_config_cache = {}
class Media:
css = {"all": ("admin/crawls/crawl_change.css",)}
@@ -739,9 +736,8 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
self.request = request
self.crawl_admin_base_config = request.archivebox_config
self.stop_reason_cache = {}
- self.persona_limit_config_cache = {}
response = super().changelist_view(request, extra_context)
- cl = getattr(response, "context_data", {}).get("cl") if hasattr(response, "context_data") else None
+ cl = response.context_data.get("cl")
if cl is not None and not self.should_annotate_snapshot_counts(request):
self.hydrate_visible_snapshot_counts(cl.result_list)
return response
@@ -790,7 +786,6 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
self.request = request
self.crawl_admin_base_config = request.archivebox_config
self.stop_reason_cache = {}
- self.persona_limit_config_cache = {}
crawl = self.get_object(request, object_id)
if crawl:
self.hydrate_visible_snapshot_counts([crawl])
@@ -982,7 +977,7 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
if obj.pk in self.stop_reason_cache:
return self.stop_reason_cache[obj.pk]
- output_dir = obj.output_dir_for_config(self.crawl_admin_base_config)
+ output_dir = obj.output_dir
config = self.limit_config_for_crawl(obj, output_dir)
reason = obj.stop_reason(
config=config,
@@ -994,22 +989,13 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
return reason
def limit_config_for_crawl(self, obj, output_dir):
- config = {
- "CRAWL_DIR": str(output_dir),
- "CRAWL_MAX_URLS": self.crawl_admin_base_config.CRAWL_MAX_URLS,
- "CRAWL_MAX_SIZE": self.crawl_admin_base_config.CRAWL_MAX_SIZE,
- "CRAWL_TIMEOUT": self.crawl_admin_base_config.CRAWL_TIMEOUT,
- "SNAPSHOT_MAX_SIZE": self.crawl_admin_base_config.SNAPSHOT_MAX_SIZE,
- }
- if obj.persona_id:
- if obj.persona_id not in self.persona_limit_config_cache:
- self.persona_limit_config_cache[obj.persona_id] = {
- key: value for key, value in obj.persona.get_derived_config().items() if key in config
- }
- config.update(self.persona_limit_config_cache[obj.persona_id])
- if obj.config:
- config.update({key: value for key, value in obj.config.items() if key in config})
- return config
+ from archivebox.config.common import get_config
+
+ return get_config(crawl=obj).for_crawl_runtime(
+ crawl=obj,
+ persona=obj.resolve_persona(),
+ crawl_output_dir=output_dir,
+ )
@admin.display(description="Status", ordering="status")
def status_with_stop_reason(self, obj):
@@ -1106,12 +1092,10 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
@admin.display(description="Snapshots")
def snapshots_changelist(self, obj):
- request = getattr(self, "request", None)
+ request = self.request
snapshot_changelist = reverse("admin:core_snapshot_changelist")
scoped_params = {"crawl_id": str(obj.pk)}
full_url = f"{snapshot_changelist}?{urlencode(scoped_params)}"
- if request is None:
- return format_html('Open snapshots changelist', full_url)
snapshot_admin = self.admin_site._registry[Snapshot]
changelist_request = copy(request)
@@ -1305,7 +1289,7 @@ class CrawlScheduleAdmin(BaseModelAdmin):
return self.fieldsets
def save_model(self, request, obj, form, change):
- if not obj.created_by_id and getattr(request, "user", None) and request.user.is_authenticated:
+ if not obj.created_by_id and request.user.is_authenticated:
obj.created_by = request.user
super().save_model(request, obj, form, change)
@@ -1315,14 +1299,14 @@ class CrawlScheduleAdmin(BaseModelAdmin):
@admin.display(description="# Crawls", ordering="crawl_count")
def num_crawls(self, obj):
- count = getattr(obj, "crawl_count", None)
+ count = obj.__dict__.get("crawl_count")
if count is None:
count = obj.crawl_set.count()
return count
@admin.display(description="# Snapshots", ordering="snapshot_count")
def num_snapshots(self, obj):
- count = getattr(obj, "snapshot_count", None)
+ count = obj.__dict__.get("snapshot_count")
if count is None:
count = Snapshot.objects.filter(crawl__schedule=obj).count()
return count
@@ -1338,7 +1322,7 @@ class CrawlScheduleAdmin(BaseModelAdmin):
crawl_ids = obj.crawl_set.values_list("pk", flat=True)
return render_snapshots_list(
Snapshot.objects.filter(crawl_id__in=crawl_ids),
- request=getattr(self, "request", None),
+ request=self.request,
prefix="schedule_snapshots",
)
diff --git a/archivebox/crawls/migrations/0016_hydrate_crawl_permissions.py b/archivebox/crawls/migrations/0016_hydrate_crawl_permissions.py
index 66e5dc19..1377faac 100644
--- a/archivebox/crawls/migrations/0016_hydrate_crawl_permissions.py
+++ b/archivebox/crawls/migrations/0016_hydrate_crawl_permissions.py
@@ -1,5 +1,6 @@
import os
import json
+import uuid
from django.db import migrations
from django.db.models import Q
@@ -9,31 +10,6 @@ VALID_PERMISSIONS = {"public", "unlisted", "private"}
BATCH_SIZE = 1000
-def legacy_bool(value):
- if value is None:
- return None
- normalized = str(value).strip().lower()
- if normalized in {"1", "true", "yes", "on"}:
- return True
- if normalized in {"0", "false", "no", "off"}:
- return False
- return None
-
-
-def permissions_from_legacy_public_flags(config):
- if str(config.get("PERMISSIONS") or "").strip():
- return None
- public_snapshots = legacy_bool(config.get("PUBLIC_SNAPSHOTS"))
- public_index = legacy_bool(config.get("PUBLIC_INDEX"))
- if public_snapshots is False:
- return "private"
- if public_index is False:
- return "unlisted"
- if public_snapshots is True or public_index is True:
- return "public"
- return None
-
-
def normalize_permissions(value, default):
value = str(value or "").strip().lower()
return value if value in VALID_PERMISSIONS else default
@@ -59,22 +35,19 @@ def raw_base_config(apps):
def resolve_permissions(config, default):
+ from archivebox.config.common import permissions_from_legacy_public_flags
+
explicit = str(config.get("PERMISSIONS") or "").strip().lower()
if explicit in VALID_PERMISSIONS:
return explicit
return permissions_from_legacy_public_flags(config) or default
-def model_has_config(model):
- try:
- model._meta.get_field("config")
- except Exception:
- return False
- return True
-
-
def id_values(pk):
- return str(pk), getattr(pk, "hex", str(pk).replace("-", ""))
+ if isinstance(pk, uuid.UUID):
+ return str(pk), pk.hex
+ pk_str = str(pk)
+ return pk_str, pk_str.replace("-", "")
def flush_batch(cursor, table_name, batch):
@@ -125,8 +98,6 @@ def _ensure_permissions_column(cursor):
def hydrate_crawl_permissions(apps, schema_editor):
Crawl = apps.get_model("crawls", "Crawl")
- User = apps.get_model("auth", "User")
- user_has_config = model_has_config(User)
base_config = raw_base_config(apps)
default_permissions = resolve_permissions(base_config, "public")
table_name = schema_editor.quote_name(Crawl._meta.db_table)
@@ -135,17 +106,13 @@ def hydrate_crawl_permissions(apps, schema_editor):
batch = []
missing_permissions = Q(permissions__isnull=True) | (Q(permissions__isnull=False) & ~Q(permissions__in=VALID_PERMISSIONS))
- for crawl in Crawl.objects.filter(missing_permissions).select_related("persona", "created_by").iterator(chunk_size=BATCH_SIZE):
+ for crawl in Crawl.objects.filter(missing_permissions).select_related("persona").iterator(chunk_size=BATCH_SIZE):
config = dict(crawl.config or {})
resolved = dict(base_config)
if crawl.persona_id:
persona_config = crawl.persona.config or {}
if isinstance(persona_config, dict):
resolved.update(persona_config)
- if user_has_config:
- user_config = crawl.created_by.config or {}
- if isinstance(user_config, dict):
- resolved.update(user_config)
resolved.update(config)
config["PERMISSIONS"] = resolve_permissions(resolved, default_permissions)
batch.append((crawl.id, config))
diff --git a/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py b/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py
index 05cbb79b..78f5417e 100644
--- a/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py
+++ b/archivebox/crawls/migrations/0018_freeze_crawl_config_snapshots.py
@@ -1,6 +1,5 @@
import json
-from django.conf import settings
from django.db import migrations
@@ -19,26 +18,31 @@ def _flush_updates(Crawl, db_alias, pending):
def freeze_existing_crawl_configs(apps, schema_editor):
from archivebox.config.common import build_crawl_config_snapshot
- from archivebox.personas.models import Persona
+ from archivebox.config.constants import CONSTANTS
+ from archivebox.personas.models import derive_persona_config
+
+ class PersonaConfigSnapshot:
+ def __init__(self, persona):
+ self.name = persona.name
+ self.config = dict(persona.config or {})
+
+ def get_derived_config(self):
+ return derive_persona_config(name=self.name, config=self.config, persona_dir=CONSTANTS.PERSONAS_DIR / self.name)
Crawl = apps.get_model("crawls", "Crawl")
- auth_app_label, auth_model_name = settings.AUTH_USER_MODEL.split(".", 1)
- User = apps.get_model(auth_app_label, auth_model_name)
+ Persona = apps.get_model("personas", "Persona")
db_alias = schema_editor.connection.alias
- rows = Crawl.objects.using(db_alias).values_list("id", "persona_id", "created_by_id", "config")
- persona_ids = {persona_id for _, persona_id, _, _ in rows if persona_id}
- user_ids = {user_id for _, _, user_id, _ in rows if user_id}
- personas = {persona.pk: persona for persona in Persona.objects.using(db_alias).filter(pk__in=persona_ids)}
- users = {user.pk: user for user in User.objects.using(db_alias).filter(pk__in=user_ids)}
+ rows = Crawl.objects.using(db_alias).values_list("id", "persona_id", "config")
+ persona_ids = {persona_id for _, persona_id, _ in rows if persona_id}
+ personas = {persona.pk: PersonaConfigSnapshot(persona) for persona in Persona.objects.using(db_alias).filter(pk__in=persona_ids)}
frozen_cache = {}
pending = []
- for crawl_id, persona_id, user_id, current_config in rows.iterator(chunk_size=BATCH_SIZE):
+ for crawl_id, persona_id, current_config in rows.iterator(chunk_size=BATCH_SIZE):
current_config = dict(current_config or {})
- cache_key = (persona_id, user_id, _config_cache_key(current_config))
+ cache_key = (persona_id, _config_cache_key(current_config))
if cache_key not in frozen_cache:
frozen_cache[cache_key] = build_crawl_config_snapshot(
- user=users.get(user_id),
persona=personas.get(persona_id),
overrides=current_config,
)
diff --git a/archivebox/crawls/models.py b/archivebox/crawls/models.py
index ac8bb9b3..0e9017e1 100755
--- a/archivebox/crawls/models.py
+++ b/archivebox/crawls/models.py
@@ -109,11 +109,10 @@ class CrawlSchedule(ModelWithUUID, ModelWithNotes):
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=build_crawl_config_snapshot(user=user, persona=persona, overrides=self.config or {}),
+ config=build_crawl_config_snapshot(persona=persona, overrides=self.config or {}),
max_depth=template.max_depth,
tags_str=template.tags_str,
persona_id=template.persona_id,
@@ -197,9 +196,9 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
return f"[...{short_id}] {first_url[:120]}"
def get_delete_after_config_value(self):
- from archivebox.config.common import get_config
+ from archivebox.config.common import resolve_delete_after_config_value
- return get_config(crawl=self).DELETE_AFTER
+ return resolve_delete_after_config_value(self.config)
def pause(self, *, save: bool = True) -> bool:
return super().pause(save=save)
@@ -286,15 +285,14 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
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)
+ config = build_crawl_config_snapshot(persona=persona, overrides=config)
if str(config.get("PERMISSIONS") or "").strip().lower() not in PERMISSIONS_VALUES:
from archivebox.config.common import get_config
- config["PERMISSIONS"] = normalize_permissions(get_config(persona=persona, user=user, include_machine=True).PERMISSIONS)
+ config["PERMISSIONS"] = normalize_permissions(get_config(persona=persona, include_machine=True).PERMISSIONS)
if "CRAWL_MAX_CONCURRENT_SNAPSHOTS" in config:
raw_concurrency = config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"]
if raw_concurrency in (None, ""):
@@ -469,11 +467,8 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
)
return crawl
- def output_dir_for_config(self, runtime_config: Mapping[str, Any] | Any) -> Path:
- """
- Construct output directory: archive/users/{username}/crawls/{YYYYMMDD}/{domain}/{crawl-id}
- Domain is extracted from the first URL in the crawl.
- """
+ @property
+ def output_dir(self) -> Path:
from archivebox.config import CONSTANTS
from archivebox.core.models import Snapshot
@@ -486,14 +481,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
break
domain = Snapshot.extract_domain_from_url(first_url) if first_url else "unknown"
- users_dir = Path(runtime_config["USERS_DIR"]) if isinstance(runtime_config, Mapping) else runtime_config.USERS_DIR
- return users_dir / self.created_by.username / CONSTANTS.CRAWLS_DIR_NAME / date_str / domain / str(self.id)
-
- @property
- def output_dir(self) -> Path:
- from archivebox.config.common import get_config
-
- output_dir = self.output_dir_for_config(get_config(resolve_plugins=False))
+ output_dir = CONSTANTS.USERS_DIR / self.created_by.username / CONSTANTS.CRAWLS_DIR_NAME / date_str / domain / str(self.id)
hyphen_dir = output_dir.with_name(str(uuid.UUID(hex=self.id.hex)))
return output_dir if output_dir.exists() or not hyphen_dir.exists() else hyphen_dir
@@ -783,7 +771,7 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
def _config_value(config: Mapping[str, Any] | Any, key: str, default: Any = None) -> Any:
if isinstance(config, Mapping):
return config.get(key, default)
- return getattr(config, key, default)
+ return config[key] if key in config else default
@classmethod
def create_scheduler_row(cls, **kwargs) -> "Crawl":
@@ -794,17 +782,12 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
kwargs.setdefault("created_at", now)
kwargs.setdefault("modified_at", now)
config = normalize_config_json_values(kwargs.get("config") or {})
- user = kwargs.get("created_by")
persona = kwargs.get("persona")
- if user is None and kwargs.get("created_by_id"):
- from django.contrib.auth import get_user_model
-
- user = get_user_model().objects.filter(pk=kwargs["created_by_id"]).first()
if persona is None and kwargs.get("persona_id"):
from archivebox.personas.models import Persona
persona = Persona.objects.filter(pk=kwargs["persona_id"]).first()
- kwargs["config"] = build_crawl_config_snapshot(user=user, persona=persona, overrides=config)
+ kwargs["config"] = build_crawl_config_snapshot(persona=persona, overrides=config)
crawl = cls(**kwargs)
if crawl.delete_at is None:
crawl.set_delete_at_from_config()
@@ -820,18 +803,20 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
) -> str:
from abx_dl.limits import CrawlLimitState
+ if output_dir is None:
+ output_dir = self.output_dir
if config is None:
from archivebox.config.common import get_config
- config = get_config(crawl=self, include_machine=False)
- if output_dir is None:
- output_dir = self.output_dir
+ config = get_config(crawl=self, include_machine=False).for_crawl_runtime(
+ crawl=self,
+ persona=self.resolve_persona(),
+ crawl_output_dir=output_dir,
+ )
limits_path = output_dir / ".abx-dl" / "limits.json"
if limits_path.exists():
- config_with_crawl_dir = {**dict(config.items())} if isinstance(config, Mapping) else config
- config_with_crawl_dir["CRAWL_DIR"] = str(output_dir)
- stop_reason = CrawlLimitState.from_config(config_with_crawl_dir).get_stop_reason()
+ stop_reason = CrawlLimitState.from_config(config).get_stop_reason()
if stop_reason:
return stop_reason
@@ -1302,10 +1287,11 @@ class Crawl(ModelWithDeleteAfter, ModelWithOutputDir, ModelWithConfig, ModelWith
from archivebox.machine.models import Binary, Machine
def get_runtime_config():
- config = get_config(crawl=self)
- if persona_runtime_overrides:
- config.update(persona_runtime_overrides)
- return config
+ return get_config(crawl=self).for_crawl_runtime(
+ crawl=self,
+ persona=persona,
+ runtime_overrides=persona_runtime_overrides,
+ )
system_task = self.get_system_task()
if system_task == "archivebox://update":
diff --git a/archivebox/machine/admin.py b/archivebox/machine/admin.py
index 13a25adb..9fc49093 100644
--- a/archivebox/machine/admin.py
+++ b/archivebox/machine/admin.py
@@ -590,9 +590,10 @@ class ProcessAdmin(BaseModelAdmin):
@admin.display(description="ArchiveResult", ordering="archiveresult__plugin")
def archiveresult_link(self, process):
- if not hasattr(process, "archiveresult"):
+ try:
+ ar = process.archiveresult
+ except Process.archiveresult.RelatedObjectDoesNotExist:
return "-"
- ar = process.archiveresult
return format_html(
'{} ← {}',
ar.id,
@@ -602,9 +603,9 @@ class ProcessAdmin(BaseModelAdmin):
@admin.display(description="Snapshot", ordering="archiveresult__snapshot__id")
def snapshot_link(self, process):
- ar = getattr(process, "archiveresult", None)
- snapshot = getattr(ar, "snapshot", None)
- if not snapshot:
+ try:
+ snapshot = process.archiveresult.snapshot
+ except Process.archiveresult.RelatedObjectDoesNotExist:
return "-"
return format_html(
'{}',
@@ -614,10 +615,9 @@ class ProcessAdmin(BaseModelAdmin):
@admin.display(description="Crawl", ordering="archiveresult__snapshot__crawl__id")
def crawl_link(self, process):
- ar = getattr(process, "archiveresult", None)
- snapshot = getattr(ar, "snapshot", None)
- crawl = getattr(snapshot, "crawl", None)
- if not crawl:
+ try:
+ crawl = process.archiveresult.snapshot.crawl
+ except Process.archiveresult.RelatedObjectDoesNotExist:
return "-"
return format_html(
'{}',
@@ -682,7 +682,10 @@ class ProcessAdmin(BaseModelAdmin):
@admin.display(description="Output", ordering="archiveresult__output_size")
def output_summary(self, process):
- output_files = getattr(getattr(process, "archiveresult", None), "output_files", {}) or {}
+ try:
+ output_files = process.archiveresult.output_files or {}
+ except Process.archiveresult.RelatedObjectDoesNotExist:
+ output_files = {}
if isinstance(output_files, str):
try:
diff --git a/archivebox/machine/models.py b/archivebox/machine/models.py
index a457ecfb..a3b6d164 100755
--- a/archivebox/machine/models.py
+++ b/archivebox/machine/models.py
@@ -19,6 +19,7 @@ from django.db.models import Q, QuerySet
from django.utils import timezone
from django.utils.functional import cached_property
+from archivebox.config import CONSTANTS
from archivebox.config.common import rprint
from archivebox.base_models.models import ModelWithDeleteAfter, ModelWithHealthStats, normalize_config_json_values
from archivebox.workers.models import BaseStateMachine, ModelWithStateMachine
@@ -442,7 +443,7 @@ class NetworkInterface(ModelWithHealthStats):
if refresh or timezone.now() >= _CURRENT_INTERFACE.modified_at + timedelta(seconds=NETWORK_INTERFACE_RECHECK_INTERVAL):
updates = ["modified_at"]
for key, value in net_info.items():
- if getattr(_CURRENT_INTERFACE, key) != value:
+ if _CURRENT_INTERFACE.__dict__.get(key) != value:
setattr(_CURRENT_INTERFACE, key, value)
updates.append(key)
if len(updates) > 1:
@@ -581,10 +582,7 @@ class Binary(ModelWithHealthStats, ModelWithStateMachine):
Get output directory for this binary's hook logs.
Path: data/machines/{machine_uuid}/binaries/{binary_name}/{binary_uuid}
"""
- from archivebox.config.common import get_config
-
- data_dir = get_config().DATA_DIR
- return data_dir / "machines" / str(self.machine_id) / "binaries" / self.name / str(self.id)
+ return CONSTANTS.DATA_DIR / "machines" / str(self.machine_id) / "binaries" / self.name / str(self.id)
def to_json(self) -> dict:
"""
@@ -1144,15 +1142,13 @@ class Process(ModelWithDeleteAfter, models.Model):
return f"Process[{self.id}] {cmd_str} ({self.status})"
def get_delete_after_config_value(self):
- from archivebox.config.common import get_config
-
value = self.env.get("DELETE_AFTER")
if value not in (None, ""):
return value
value = (self.machine.config or {}).get("DELETE_AFTER")
if value not in (None, ""):
return value
- return get_config(include_machine=False).DELETE_AFTER
+ return "0"
@classmethod
def missing_delete_at_candidates(cls):
@@ -1174,17 +1170,18 @@ class Process(ModelWithDeleteAfter, models.Model):
@property
def plugin(self) -> str:
"""Get plugin name from associated ArchiveResult (if any)."""
- if hasattr(self, "archiveresult"):
- # Inline import to avoid circular dependency
+ try:
return self.archiveresult.plugin
- return ""
+ except Process.archiveresult.RelatedObjectDoesNotExist:
+ return ""
@property
def hook_name(self) -> str:
"""Get hook name from associated ArchiveResult (if any)."""
- if hasattr(self, "archiveresult"):
+ try:
return self.archiveresult.hook_name
- return ""
+ except Process.archiveresult.RelatedObjectDoesNotExist:
+ return ""
def to_json(self) -> dict:
"""
@@ -2449,16 +2446,14 @@ class Process(ModelWithDeleteAfter, models.Model):
"""
import subprocess
from importlib.resources import files
- from archivebox.config.common import get_config
chrome_utils = files("abx_plugins.plugins.chrome").joinpath("chrome_utils.js")
if not chrome_utils.exists():
return 0
- config = get_config()
crawl_roots = [
crawls_dir
- for user_dir in config.USERS_DIR.iterdir()
+ for user_dir in CONSTANTS.USERS_DIR.iterdir()
if user_dir.is_dir()
for crawls_dir in [user_dir / "crawls"]
if crawls_dir.is_dir()
diff --git a/archivebox/misc/checks.py b/archivebox/misc/checks.py
index e1d97bb0..0a11bf8c 100644
--- a/archivebox/misc/checks.py
+++ b/archivebox/misc/checks.py
@@ -66,7 +66,7 @@ def check_data_folder(config=None, **config_kwargs) -> None:
from archivebox.config.paths import create_and_chown_dir, get_or_create_working_tmp_dir, get_or_create_working_lib_dir
config = config or get_config(**config_kwargs)
- archive_dir = config.ARCHIVE_DIR
+ archive_dir = CONSTANTS.ARCHIVE_DIR
archive_dir_exists = os.path.isdir(archive_dir)
if not archive_dir_exists:
print("[red][X] No archivebox index found in the current directory.[/red]", file=sys.stderr)
@@ -82,7 +82,7 @@ def check_data_folder(config=None, **config_kwargs) -> None:
# Create data dir subdirs
create_and_chown_dir(CONSTANTS.SOURCES_DIR)
- create_and_chown_dir(config.USERS_DIR)
+ create_and_chown_dir(CONSTANTS.USERS_DIR)
create_and_chown_dir(CONSTANTS.PERSONAS_DIR / "Default")
create_and_chown_dir(CONSTANTS.LOGS_DIR)
# create_and_chown_dir(CONSTANTS.CACHE_DIR)
@@ -211,11 +211,10 @@ def check_not_inside_source_dir():
"""Prevent running ArchiveBox from inside its source directory (would pollute repo with data files)."""
cwd = Path(os.getcwd()).resolve()
is_source_dir = (cwd / "archivebox" / "__init__.py").exists() and (cwd / "pyproject.toml").exists()
- data_dir_set_elsewhere = os.environ.get("DATA_DIR", "").strip() and Path(os.environ["DATA_DIR"]).resolve() != cwd
is_testing = "pytest" in sys.modules or "unittest" in sys.modules
- if is_source_dir and not data_dir_set_elsewhere and not is_testing:
- raise SystemExit("[!] Cannot run from source dir, set DATA_DIR or cd to a data folder first")
+ if is_source_dir and not is_testing:
+ raise SystemExit("[!] Cannot run from source dir, cd to a data folder first")
def check_data_dir_permissions(config=None, **config_kwargs):
diff --git a/archivebox/misc/db.py b/archivebox/misc/db.py
index 9288b063..a154d16b 100644
--- a/archivebox/misc/db.py
+++ b/archivebox/misc/db.py
@@ -1,7 +1,7 @@
"""
Database utility functions for ArchiveBox.
-Post-bootstrap: requires archivebox.config (DATA_DIR) and uses Django lazily
+Post-bootstrap: requires archivebox.config constants and uses Django lazily
(``from django.db import ...`` inside functions). Not safe to import pre-bootstrap.
"""
@@ -17,7 +17,7 @@ from collections.abc import Callable
from contextlib import contextmanager
from sqlite3 import OperationalError as SQLiteOperationalError
-from archivebox.config import DATA_DIR
+from archivebox.config import CONSTANTS
from archivebox.misc.util import enforce_types
@@ -79,7 +79,7 @@ def run_db_analyze_batch(
return []
next_table, *rest = remaining
- raw_conn = getattr(connection, "connection", None)
+ raw_conn = connection.connection
progress_handler_set = False
if raw_conn is not None and max_seconds_per_table > 0:
deadline = time.monotonic() + max_seconds_per_table
@@ -117,7 +117,7 @@ def compact_command(cmdline: list[str] | None, fallback: str = "") -> str:
return " ".join([Path(parts[0]).name, *parts[1:]])[:220]
-def sqlite_lock_holders(db_path: Path = DATA_DIR / "index.sqlite3") -> list[str]:
+def sqlite_lock_holders(db_path: Path = CONSTANTS.DATABASE_FILE) -> list[str]:
import psutil
db_path = db_path.resolve()
@@ -146,7 +146,7 @@ def sqlite_lock_holders(db_path: Path = DATA_DIR / "index.sqlite3") -> list[str]
return holders
-def log_sqlite_lock_holders(console: Any, *, db_path: Path = DATA_DIR / "index.sqlite3", limit: int = 8) -> None:
+def log_sqlite_lock_holders(console: Any, *, db_path: Path = CONSTANTS.DATABASE_FILE, limit: int = 8) -> None:
holders = sqlite_lock_holders(db_path)
if holders:
console.print("[yellow] DB holders:[/yellow]")
@@ -346,7 +346,7 @@ HISTORICAL_GHOST_MIGRATIONS: frozenset[tuple[str, str]] = frozenset(
@enforce_types
-def migration_state(out_dir: Path = DATA_DIR) -> tuple[list[str], list[str], dict[str, str]]:
+def migration_state(out_dir: Path = CONSTANTS.DATA_DIR) -> tuple[list[str], list[str], dict[str, str]]:
"""Cheaply compare migration files to django_migrations without invoking migrate."""
from django.apps import apps
from django.db import connection
@@ -373,7 +373,7 @@ def migration_state(out_dir: Path = DATA_DIR) -> tuple[list[str], list[str], dic
loader.load_disk()
for (app_label, migration_name), migration in loader.disk_migrations.items():
disk_migrations.add((app_label, migration_name))
- for replaced_app, replaced_name in getattr(migration, "replaces", ()) or ():
+ for replaced_app, replaced_name in migration.replaces or ():
squashed_replaced.add((replaced_app, replaced_name))
applied = {(app, name) for app, name in applied if app in app_labels}
@@ -392,14 +392,19 @@ def migration_state(out_dir: Path = DATA_DIR) -> tuple[list[str], list[str], dic
@enforce_types
-def pending_migrations(out_dir: Path = DATA_DIR) -> list[str]:
+def pending_migrations(out_dir: Path = CONSTANTS.DATA_DIR) -> list[str]:
"""Return migration files on disk that have not been applied yet."""
pending, _missing_from_code, _rollback_targets = migration_state(out_dir=out_dir)
return pending
@enforce_types
-def apply_migrations(out_dir: Path = DATA_DIR, stdout: TextIO | None = None, stderr: TextIO | None = None, verbosity: int = 1) -> list[str]:
+def apply_migrations(
+ out_dir: Path = CONSTANTS.DATA_DIR,
+ stdout: TextIO | None = None,
+ stderr: TextIO | None = None,
+ verbosity: int = 1,
+) -> list[str]:
"""Apply pending Django migrations"""
from django.core.management import call_command
diff --git a/archivebox/misc/logging_util.py b/archivebox/misc/logging_util.py
index a05d8266..ac4b2718 100644
--- a/archivebox/misc/logging_util.py
+++ b/archivebox/misc/logging_util.py
@@ -1,7 +1,7 @@
__package__ = "archivebox"
# Post-bootstrap CLI logging helpers (event loggers, progress bars, formatters).
-# Requires archivebox.config to be loaded — imports DATA_DIR, get_config, and
+# Requires archivebox.config to be loaded — imports CONSTANTS/get_config and
# references Django ORM types. For pre-bootstrap logging primitives use
# misc/logging.py, which has no archivebox or Django dependencies.
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
from rich import print
from rich.panel import Panel
-from archivebox.config import DATA_DIR, VERSION
+from archivebox.config import CONSTANTS, VERSION
from archivebox.config.common import get_config
from archivebox.misc.util import enforce_types
from archivebox.misc.logging import ANSI
@@ -171,7 +171,9 @@ def log_list_finished(snapshots):
def log_removal_started(snapshots, yes: bool):
- count = snapshots.count() if hasattr(snapshots, "count") else len(snapshots)
+ from django.db.models import QuerySet
+
+ count = snapshots.count() if isinstance(snapshots, QuerySet) else len(snapshots)
print(f"[yellow3][i] Found {count} matching URLs to remove.[/]")
file_counts = [s.num_outputs for s in snapshots if os.access(s.output_dir, os.R_OK)]
print(
@@ -203,7 +205,7 @@ def log_removal_finished(remaining_links: int, removed_links: int):
@enforce_types
-def pretty_path(path: Path | str, pwd: Path | str = DATA_DIR, color: bool = True) -> str:
+def pretty_path(path: Path | str, pwd: Path | str = CONSTANTS.DATA_DIR, color: bool = True) -> str:
"""convert paths like .../ArchiveBox/archivebox/../output/abc into output/abc"""
pwd = str(Path(pwd)) # .resolve()
path = str(path)
diff --git a/archivebox/misc/paginators.py b/archivebox/misc/paginators.py
index fd12c292..602a1082 100644
--- a/archivebox/misc/paginators.py
+++ b/archivebox/misc/paginators.py
@@ -4,6 +4,7 @@ from django.core.paginator import Paginator
from django.core.paginator import Page
from django.core.paginator import EmptyPage
from django.db import connection
+from django.db.models import QuerySet
from django.utils.functional import cached_property
@@ -19,9 +20,13 @@ class CountlessPage(Page):
class CountlessPaginator(Paginator):
has_exact_count = False
+ def __init__(self, *args, **kwargs):
+ self._count_hint = 0
+ super().__init__(*args, **kwargs)
+
@cached_property
def count(self):
- return getattr(self, "_count_hint", 0)
+ return self._count_hint
@cached_property
def num_pages(self):
@@ -56,16 +61,29 @@ class AcceleratedPaginator(Paginator):
@cached_property
def count(self):
- query = getattr(self.object_list, "query", None)
- if query is not None and (getattr(query, "distinct", False) or getattr(getattr(query, "where", None), "children", None)):
+ if not isinstance(self.object_list, QuerySet):
+ return super().count
+
+ query = self.object_list.query
+ count_hint = self.object_list.__dict__.get("_archivebox_count_hint")
+ if count_hint is None:
+ count_hint = query.__dict__.get("_archivebox_count_hint")
+ if count_hint is not None:
+ model = self.object_list.model
+ if count_hint == "model_estimate":
+ return self._model_count_estimate(model)
+ if callable(count_hint):
+ return count_hint()
+ return count_hint
+
+ if query.distinct or query.where.children:
# fallback to normal count method on filtered queryset
return super().count
- model = getattr(self.object_list, "model", None)
- if model is None:
- return super().count
-
# otherwise count total rows in a separate fast query
+ return self._model_count_estimate(self.object_list.model)
+
+ def _model_count_estimate(self, model):
if connection.vendor == "sqlite":
table_name = model._meta.db_table
with connection.cursor() as cursor:
diff --git a/archivebox/misc/serve_static.py b/archivebox/misc/serve_static.py
index 1ec3ab33..5e5bd166 100644
--- a/archivebox/misc/serve_static.py
+++ b/archivebox/misc/serve_static.py
@@ -2,6 +2,7 @@ import html
import json
import re
import os
+import sys
import stat
import asyncio
import posixpath
@@ -17,6 +18,7 @@ from pathlib import Path
from urllib.parse import urlencode
from django import template
+from django.core.handlers.asgi import ASGIRequest
from django.contrib.staticfiles import finders
from django.template import TemplateDoesNotExist, loader
from django.views import static
@@ -117,7 +119,7 @@ def _render_mhtml_preview_document(filename: str, output_path: str) -> str:
def _format_direntry_timestamp(stat_result: os.stat_result) -> str:
- timestamp = getattr(stat_result, "st_birthtime", None) or stat_result.st_mtime
+ timestamp = stat_result.st_birthtime if sys.platform == "darwin" else stat_result.st_mtime
return datetime.fromtimestamp(timestamp).strftime("%Y-%m-%d %H:%M")
@@ -333,7 +335,7 @@ mimetypes.add_type("multipart/related", ".mhtml")
mimetypes.add_type("multipart/related", ".mht")
try:
- _markdown = getattr(importlib.import_module("markdown"), "markdown")
+ _markdown = importlib.import_module("markdown").markdown
except ImportError:
_markdown: Callable[..., str] | None = None
@@ -716,10 +718,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
https://github.com/satchamo/django/commit/2ce75c5c4bee2a858c0214d136bfcd351fcde11d
"""
assert document_root
- config = getattr(request, "archivebox_config", None)
- if config is None:
- config = get_config(resolve_plugins=False)
- request.archivebox_config = config
+ config = request.archivebox_config
fullpath, path = _resolve_archive_path(document_root, path)
if os.access(fullpath, os.R_OK) and fullpath.is_dir():
if request.GET.get("download") == "zip" and show_indexes:
@@ -727,7 +726,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
fullpath,
path,
is_archive_replay=is_archive_replay,
- use_async_stream=hasattr(request, "scope"),
+ use_async_stream=isinstance(request, ASGIRequest),
config=config,
)
if show_indexes:
@@ -872,7 +871,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
fullpath.name,
raw_output_path,
wacz_path=fullpath,
- fallback_url=getattr(request, "archivebox_snapshot_url", "") or "",
+ fallback_url=request.archivebox_snapshot_url or "",
last_modified=http_date(statobj.st_mtime),
etag=etag or "",
cache_control=(
@@ -978,7 +977,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
# setup response object
ranged_file = RangedFileReader(open(fullpath, "rb"))
response = StreamingHttpResponse(
- _stream_ranged_file_async(ranged_file) if hasattr(request, "scope") else ranged_file,
+ _stream_ranged_file_async(ranged_file) if isinstance(request, ASGIRequest) else ranged_file,
content_type=content_type,
)
response.headers["Last-Modified"] = http_date(statobj.st_mtime)
diff --git a/archivebox/misc/toml_util.py b/archivebox/misc/toml_util.py
index 36b0d111..33daaa40 100644
--- a/archivebox/misc/toml_util.py
+++ b/archivebox/misc/toml_util.py
@@ -96,7 +96,7 @@ class JSONSchemaWithLambdas(GenerateJsonSchema):
def better_toml_dump_str(val: Any) -> str:
try:
- dump_str = cast(Callable[[Any], str], getattr(toml.encoder, "_dump_str"))
+ dump_str = cast(Callable[[Any], str], toml.encoder._dump_str)
return dump_str(val)
except Exception:
# if we hit any of toml's numerous encoding bugs,
diff --git a/archivebox/misc/util.py b/archivebox/misc/util.py
index ac5fa500..7c12b321 100644
--- a/archivebox/misc/util.py
+++ b/archivebox/misc/util.py
@@ -474,7 +474,7 @@ class ExtendedEncoder(pyjson.JSONEncoder):
def default(self, o):
cls_name = o.__class__.__name__
- if hasattr(o, "_asdict"):
+ if isinstance(o, tuple) and "_asdict" in vars(type(o)):
return o._asdict()
elif isinstance(o, bytes):
diff --git a/archivebox/personas/migrations/0004_hydrate_persona_permissions.py b/archivebox/personas/migrations/0004_hydrate_persona_permissions.py
index 7238c678..7b7bd22e 100644
--- a/archivebox/personas/migrations/0004_hydrate_persona_permissions.py
+++ b/archivebox/personas/migrations/0004_hydrate_persona_permissions.py
@@ -1,5 +1,6 @@
import os
import json
+import uuid
from django.db import migrations
from django.db.models import Q
@@ -9,31 +10,6 @@ VALID_PERMISSIONS = {"public", "unlisted", "private"}
BATCH_SIZE = 1000
-def legacy_bool(value):
- if value is None:
- return None
- normalized = str(value).strip().lower()
- if normalized in {"1", "true", "yes", "on"}:
- return True
- if normalized in {"0", "false", "no", "off"}:
- return False
- return None
-
-
-def permissions_from_legacy_public_flags(config):
- if str(config.get("PERMISSIONS") or "").strip():
- return None
- public_snapshots = legacy_bool(config.get("PUBLIC_SNAPSHOTS"))
- public_index = legacy_bool(config.get("PUBLIC_INDEX"))
- if public_snapshots is False:
- return "private"
- if public_index is False:
- return "unlisted"
- if public_snapshots is True or public_index is True:
- return "public"
- return None
-
-
def normalize_permissions(value, default):
value = str(value or "").strip().lower()
return value if value in VALID_PERMISSIONS else default
@@ -59,22 +35,19 @@ def raw_base_config(apps):
def resolve_permissions(config, default):
+ from archivebox.config.common import permissions_from_legacy_public_flags
+
explicit = str(config.get("PERMISSIONS") or "").strip().lower()
if explicit in VALID_PERMISSIONS:
return explicit
return permissions_from_legacy_public_flags(config) or default
-def model_has_config(model):
- try:
- model._meta.get_field("config")
- except Exception:
- return False
- return True
-
-
def id_values(pk):
- return str(pk), getattr(pk, "hex", str(pk).replace("-", ""))
+ if isinstance(pk, uuid.UUID):
+ return str(pk), pk.hex
+ pk_str = str(pk)
+ return pk_str, pk_str.replace("-", "")
def flush_batch(cursor, table_name, batch):
@@ -120,8 +93,6 @@ def _ensure_permissions_column(cursor):
def hydrate_persona_permissions(apps, schema_editor):
Persona = apps.get_model("personas", "Persona")
- User = apps.get_model("auth", "User")
- user_has_config = model_has_config(User)
base_config = raw_base_config(apps)
default_permissions = resolve_permissions(base_config, "public")
table_name = schema_editor.quote_name(Persona._meta.db_table)
@@ -130,13 +101,9 @@ def hydrate_persona_permissions(apps, schema_editor):
batch = []
missing_permissions = Q(permissions__isnull=True) | (Q(permissions__isnull=False) & ~Q(permissions__in=VALID_PERMISSIONS))
- for persona in Persona.objects.filter(missing_permissions).select_related("created_by").iterator(chunk_size=BATCH_SIZE):
+ for persona in Persona.objects.filter(missing_permissions).iterator(chunk_size=BATCH_SIZE):
config = dict(persona.config or {})
resolved = dict(base_config)
- if user_has_config:
- user_config = persona.created_by.config or {}
- if isinstance(user_config, dict):
- resolved.update(user_config)
resolved.update(config)
config["PERMISSIONS"] = resolve_permissions(resolved, default_permissions)
batch.append((persona.id, config))
diff --git a/archivebox/personas/models.py b/archivebox/personas/models.py
index 5b471069..fffca6dc 100644
--- a/archivebox/personas/models.py
+++ b/archivebox/personas/models.py
@@ -16,6 +16,7 @@ import sys
from contextlib import contextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any
+from collections.abc import Mapping
from django.db import models
from django.db.models.fields.json import KT
@@ -59,6 +60,26 @@ VOLATILE_PROFILE_FILE_NAMES = {
}
+def derive_persona_config(*, name: str, config: Mapping[str, Any] | None, persona_dir: Path) -> dict[str, Any]:
+ derived = dict(config or {})
+
+ if "CHROME_USER_DATA_DIR" not in derived:
+ derived["CHROME_USER_DATA_DIR"] = str(persona_dir / "chrome_profile")
+ if "CHROME_DOWNLOADS_DIR" not in derived:
+ derived["CHROME_DOWNLOADS_DIR"] = str(persona_dir / "chrome_downloads")
+
+ cookies_path = persona_dir / "cookies.txt"
+ if "COOKIES_FILE" not in derived and cookies_path.exists():
+ derived["COOKIES_FILE"] = str(cookies_path)
+
+ auth_path = persona_dir / "auth.json"
+ if "AUTH_STORAGE_FILE" not in derived and auth_path.exists():
+ derived["AUTH_STORAGE_FILE"] = str(auth_path)
+
+ derived["ACTIVE_PERSONA"] = name
+ return derived
+
+
class Persona(ModelWithConfig):
"""
Browser persona/profile for archiving sessions.
@@ -99,8 +120,7 @@ class Persona(ModelWithConfig):
if str(config.get("PERMISSIONS") or "").strip().lower() not in PERMISSIONS_VALUES:
from archivebox.config.common import get_config
- user = self.created_by if self.created_by_id else None
- config["PERMISSIONS"] = normalize_permissions(get_config(user=user, include_machine=True).PERMISSIONS)
+ config["PERMISSIONS"] = normalize_permissions(get_config(include_machine=True).PERMISSIONS)
self.config = config
update_fields = kwargs.get("update_fields")
if update_fields is not None:
@@ -151,22 +171,7 @@ class Persona(ModelWithConfig):
- AUTH_STORAGE_FILE (derived from persona path, if file exists)
- ACTIVE_PERSONA (set to this persona's name)
"""
- derived = dict(self.config or {})
-
- # Add derived paths (don't override if explicitly set in config)
- if "CHROME_USER_DATA_DIR" not in derived:
- derived["CHROME_USER_DATA_DIR"] = self.CHROME_USER_DATA_DIR
- if "CHROME_DOWNLOADS_DIR" not in derived:
- derived["CHROME_DOWNLOADS_DIR"] = self.CHROME_DOWNLOADS_DIR
- if "COOKIES_FILE" not in derived and self.COOKIES_FILE:
- derived["COOKIES_FILE"] = self.COOKIES_FILE
- if "AUTH_STORAGE_FILE" not in derived and self.AUTH_STORAGE_FILE:
- derived["AUTH_STORAGE_FILE"] = self.AUTH_STORAGE_FILE
-
- # Always set ACTIVE_PERSONA to this persona's name
- derived["ACTIVE_PERSONA"] = self.name
-
- return derived
+ return derive_persona_config(name=self.name, config=self.config, persona_dir=self.path)
def ensure_dirs(self) -> None:
"""Create persona directories if they don't exist."""
diff --git a/archivebox/plugins/discovery.py b/archivebox/plugins/discovery.py
index ecc20bfc..46d95484 100644
--- a/archivebox/plugins/discovery.py
+++ b/archivebox/plugins/discovery.py
@@ -1,7 +1,6 @@
__package__ = "archivebox.plugins"
import json
-import os
from collections.abc import Iterable
from functools import lru_cache
from pathlib import Path
@@ -26,9 +25,7 @@ class PluginSpecialConfig(TypedDict):
BUILTIN_PLUGINS_DIR = Path(get_plugins_dir()).resolve()
-USER_PLUGINS_DIR = Path(
- os.environ.get("ARCHIVEBOX_USER_PLUGINS_DIR") or str(CONSTANTS.USER_PLUGINS_DIR),
-).expanduser()
+USER_PLUGINS_DIR = CONSTANTS.USER_PLUGINS_DIR
def iter_plugin_dirs() -> list[Path]:
@@ -167,7 +164,7 @@ def discover_plugins_that_provide_interface(
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
- if not all(hasattr(module, attr) for attr in required_attrs):
+ if not all(attr in vars(module) for attr in required_attrs):
continue
if plugin_prefix:
diff --git a/archivebox/plugins/forms.py b/archivebox/plugins/forms.py
index 95cae2f1..5f6b662e 100644
--- a/archivebox/plugins/forms.py
+++ b/archivebox/plugins/forms.py
@@ -9,7 +9,7 @@ from typing import Any
from django import forms
from django.utils.html import format_html
-from archivebox.config.common import get_config
+from archivebox.config.common import ArchiveBoxConfig, get_config
from archivebox.plugins.discovery import discover_plugin_configs, get_plugin_icon, get_plugins
@@ -250,6 +250,7 @@ def _coerce_plugin_config_value(raw_value: Any, schema: Mapping[str, Any]) -> An
class PluginConfigFormMixin:
plugin_groups: list[dict[str, Any]]
+ allow_crawl_execution_config_fields = True
def build_plugin_groups(self, runtime_config: Mapping[str, Any] | None = None) -> None:
all_plugins = get_plugins()
@@ -314,7 +315,10 @@ class PluginConfigFormMixin:
config_fields = [
self._build_plugin_config_field(str(plugin_name), str(config_key), prop_schema, runtime_config)
for config_key, prop_schema in properties.items()
- if isinstance(prop_schema, dict)
+ if (
+ isinstance(prop_schema, dict)
+ and (self.allow_crawl_execution_config_fields or ArchiveBoxConfig.scope_for_key(str(config_key)) == "crawl_frozen")
+ )
]
cards.append(
{
@@ -427,6 +431,8 @@ class PluginConfigFormMixin:
input_name = _plugin_config_input_name(plugin_name, config_key)
if input_name not in self.data:
continue
+ if not self.allow_crawl_execution_config_fields and ArchiveBoxConfig.scope_for_key(str(config_key)) != "crawl_frozen":
+ continue
raw_value: Any = self.data.get(input_name)
if "array" in _schema_types(prop_schema) and isinstance(prop_schema.get("enum"), list):
@@ -595,5 +601,6 @@ def get_plugin_config_binary_urls(runtime_config: Mapping[str, Any]) -> dict[str
)
if binary is None and name != value:
binary = Binary.objects.get_valid_binary(name, machine=machine)
- urls[key] = get_installed_binary_change_url(getattr(binary, "name", name), binary) or get_environment_binary_url(name)
+ binary_name = binary.name if binary is not None else name
+ urls[key] = get_installed_binary_change_url(binary_name, binary) or get_environment_binary_url(name)
return urls
diff --git a/archivebox/plugins/hooks.py b/archivebox/plugins/hooks.py
index c3118557..efb368d9 100644
--- a/archivebox/plugins/hooks.py
+++ b/archivebox/plugins/hooks.py
@@ -46,7 +46,7 @@ import json
import os
from collections.abc import Mapping
from pathlib import Path
-from typing import TYPE_CHECKING, Any, Optional, Protocol, TypeGuard
+from typing import TYPE_CHECKING, Any, Optional, Protocol, TypeGuard, runtime_checkable
from archivebox.config.constants import CONSTANTS
from archivebox.config.version import VERSION
@@ -62,12 +62,13 @@ if TYPE_CHECKING:
from archivebox.machine.models import Process
+@runtime_checkable
class ConfigDump(Protocol):
def as_dict(self) -> dict[str, Any]: ...
def _has_config_dump(config: object) -> TypeGuard[ConfigDump]:
- return callable(getattr(config, "as_dict", None))
+ return isinstance(config, ConfigDump)
def _config_to_overrides(config: ConfigLookup | Mapping[str, Any] | None) -> dict[str, Any]:
@@ -259,13 +260,13 @@ def run_hook(
This is the low-level hook executor that creates a Process record and
uses Process.launch() for subprocess management.
- Config is passed to hooks via environment variables. Caller MUST use
- get_config() to merge all sources (file, env, machine, crawl, snapshot).
+ Config is passed to hooks via environment variables. Crawl/snapshot callers
+ should pass the runtime config produced by for_crawl_runtime().
Args:
script: Path to the hook script (.sh, .py, or .js)
output_dir: Working directory for the script (where output files go)
- config: Optional pre-merged config dict from get_config(crawl=..., snapshot=...).
+ config: Optional runtime config dict from for_crawl_runtime().
If omitted, pass scope/override args using kwargs prefixed with config_.
timeout: Maximum execution time in seconds
If None, auto-detects from PLUGINNAME_TIMEOUT config (fallback to TIMEOUT, default 300)
@@ -277,18 +278,22 @@ def run_hook(
Example:
from archivebox.config.common import get_config
- config = get_config(crawl=my_crawl, snapshot=my_snapshot)
+ config = get_config(crawl=my_crawl, snapshot=my_snapshot).for_crawl_runtime(crawl=my_crawl, snapshot=my_snapshot)
process = run_hook(hook_path, output_dir, config=config, url=url, snapshot_id=id)
if process.status == 'exited':
records = process.get_records() # Get parsed JSONL output
"""
from archivebox.machine.models import Process, Machine, NetworkInterface
- from archivebox.config.common import get_config
+ from archivebox.config.common import get_config, normalize_runtime_config
import sys
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()
+ config_overrides = _config_to_overrides(config)
+ resolved_config = get_config(overrides=config_overrides, **config_scope)
+ hook_config = normalize_runtime_config(
+ config_overrides if config is not None else resolved_config.for_crawl(),
+ json_safe=False,
+ )
# Auto-detect timeout from plugin config if not explicitly provided
if timeout is None:
@@ -363,31 +368,16 @@ def run_hook(
# Set up environment with base paths
env = os.environ.copy()
- env["DATA_DIR"] = str(resolved_config.DATA_DIR)
- env["ARCHIVE_DIR"] = str(resolved_config.ARCHIVE_DIR)
- env["ABX_RUNTIME"] = "archivebox"
+ env["DATA_DIR"] = str(CONSTANTS.DATA_DIR)
env["LIBRARY_VERSION"] = VERSION
env.setdefault("MACHINE_ID", os.environ.get("MACHINE_ID", CONSTANTS.MACHINE_ID))
-
- resolved_output_dir = output_dir.resolve()
- snap_dir = _model_output_dir_from_child_path(resolved_output_dir, CONSTANTS.SNAPSHOTS_DIR_NAME)
- crawl_dir = _model_output_dir_from_child_path(resolved_output_dir, CONSTANTS.CRAWLS_DIR_NAME)
+ snap_dir = hook_config.get("SNAP_DIR") or _model_output_dir_from_child_path(output_dir, CONSTANTS.SNAPSHOTS_DIR_NAME)
+ crawl_dir = hook_config.get("CRAWL_DIR") or _model_output_dir_from_child_path(output_dir, CONSTANTS.CRAWLS_DIR_NAME)
if snap_dir:
env["SNAP_DIR"] = str(snap_dir)
if crawl_dir:
env["CRAWL_DIR"] = str(crawl_dir)
- crawl_id = kwargs.get("_crawl_id") or kwargs.get("crawl_id")
- if crawl_id:
- try:
- from archivebox.crawls.models import Crawl
-
- crawl = Crawl.objects.filter(id=crawl_id).first()
- if crawl:
- env["CRAWL_DIR"] = str(crawl.output_dir)
- except Exception:
- pass
-
# Export runtime library roots; abx-dl/abxpkg own executable lookup env.
lib_dir = resolved_config.LIB_DIR
if lib_dir:
@@ -423,7 +413,6 @@ def run_hook(
"NODE_MODULES_DIR",
"NODE_MODULE_DIR",
"DATA_DIR",
- "ARCHIVE_DIR",
"MACHINE_ID",
"SNAP_DIR",
"CRAWL_DIR",
diff --git a/archivebox/plugins/views.py b/archivebox/plugins/views.py
index 9ef503d8..fb67d21e 100644
--- a/archivebox/plugins/views.py
+++ b/archivebox/plugins/views.py
@@ -82,7 +82,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}/change/"
+ return machine.admin_change_url or f"/admin/machine/machine/{machine.id}/change/"
except Exception:
return None
diff --git a/archivebox/progressmonitor/views.py b/archivebox/progressmonitor/views.py
index c6a3086d..a73ba265 100644
--- a/archivebox/progressmonitor/views.py
+++ b/archivebox/progressmonitor/views.py
@@ -345,9 +345,7 @@ def live_progress_view(request):
persona_objects_by_id = {}
persona_objects_by_name = {}
persona_ids = {crawl["persona_id"] for crawl in active_crawls_list if crawl["persona_id"]}
- persona_names = {
- str((crawl["config"] or {}).get("DEFAULT_PERSONA") or "Default") for crawl in active_crawls_list if not crawl["persona_id"]
- }
+ persona_names = {"Default"} if any(not crawl["persona_id"] for crawl in active_crawls_list) else set()
if persona_ids or persona_names:
from archivebox.personas.models import Persona
@@ -813,7 +811,7 @@ def live_progress_view(request):
urls_preview = crawl["urls"][:60] if crawl["urls"] else None
crawl_tags = [tag.strip() for tag in (crawl["tags_str"] or "").replace("\n", ",").split(",") if tag.strip()]
persona_details = persona_details_by_id.get(str(crawl["persona_id"])) if crawl["persona_id"] else None
- persona_name = persona_details["name"] if persona_details else str((crawl["config"] or {}).get("DEFAULT_PERSONA") or "Default")
+ persona_name = persona_details["name"] if persona_details else "Default"
persona_details = persona_details or persona_details_by_name.get(persona_name)
crawl_output_size = crawl_output_sizes_by_crawl.get(crawl_id, 0)
avg_snapshot_size = int(crawl_output_size / completed_snapshots) if completed_snapshots else 0
diff --git a/archivebox/search/admin.py b/archivebox/search/admin.py
index e588130f..d7c7c698 100644
--- a/archivebox/search/admin.py
+++ b/archivebox/search/admin.py
@@ -19,8 +19,8 @@ class SearchResultsChangeList(ChangeList):
def __init__(self, request, *args, **kwargs):
"""Capture normalized search mode before Django builds results."""
- self.search_mode = get_search_mode(request.GET.get("search_mode"), config=getattr(request, "archivebox_config", None))
- self.search_mode_backend = get_search_mode_backend(self.search_mode, config=getattr(request, "archivebox_config", None))
+ self.search_mode = get_search_mode(request.GET.get("search_mode"), config=request.archivebox_config)
+ self.search_mode_backend = get_search_mode_backend(self.search_mode, config=request.archivebox_config)
super().__init__(request, *args, **kwargs)
self.embedded_changelist = request.GET.get("_embedded") == "crawl"
@@ -31,7 +31,7 @@ class SearchResultsChangeList(ChangeList):
self.opts.model_name == "snapshot"
and self.query
and self.result_count == 0
- and get_search_mode_base(self.search_mode, config=getattr(request, "archivebox_config", None)) == "deep"
+ and get_search_mode_base(self.search_mode, config=request.archivebox_config) == "deep"
and self.search_mode_backend,
)
@@ -55,13 +55,11 @@ class SearchResultsAdminMixin(admin.ModelAdmin):
def get_default_search_mode(self):
"""Return the default search mode for the current request config."""
- request = getattr(self, "request", None)
- return get_default_search_mode(config=getattr(request, "archivebox_config", None))
+ return get_default_search_mode(config=self.request.archivebox_config)
def get_search_mode_options(self):
"""Return selector options for the current request config."""
- request = getattr(self, "request", None)
- return get_search_mode_options(config=getattr(request, "archivebox_config", None))
+ return get_search_mode_options(config=self.request.archivebox_config)
def get_search_results(self, request, queryset, search_term: str):
"""Apply admin search semantics to a changelist queryset."""
@@ -69,14 +67,14 @@ class SearchResultsAdminMixin(admin.ModelAdmin):
search_term = search_term.strip()
if not search_term:
return super().get_search_results(request, queryset, search_term)
- search_mode = get_search_mode(request.GET.get("search_mode"), config=getattr(request, "archivebox_config", None))
+ search_mode = get_search_mode(request.GET.get("search_mode"), config=request.archivebox_config)
if queryset.model._meta.label_lower == "core.snapshot" and request.GET.get("_embedded") != "crawl":
cached_ids = get_cached_admin_search_ids(request)
if cached_ids is not None:
return queryset.filter(pk__in=cached_ids) if cached_ids else queryset.none(), False
return queryset.none(), False
- if get_search_mode_base(search_mode, config=getattr(request, "archivebox_config", None)) == "meta":
+ if get_search_mode_base(search_mode, config=request.archivebox_config) == "meta":
qs, use_distinct = super().get_search_results(request, queryset, search_term)
return qs, use_distinct
if request.GET.get("_embedded") == "crawl":
@@ -85,7 +83,7 @@ class SearchResultsAdminMixin(admin.ModelAdmin):
pk__in=query_search_index(
search_term,
search_mode=search_mode,
- config=getattr(request, "archivebox_config", None),
+ config=request.archivebox_config,
).values("pk"),
), False
except Exception as err:
diff --git a/archivebox/search/backends.py b/archivebox/search/backends.py
index c23b4e84..135faad7 100644
--- a/archivebox/search/backends.py
+++ b/archivebox/search/backends.py
@@ -12,16 +12,16 @@ _search_backends_cache: dict | None = None
@contextmanager
def search_backend_env(config: dict[str, Any] | None = None, **config_kwargs: Any):
- """Temporarily expose resolved config through os.environ for backend code."""
+ """Temporarily expose resolved search config through os.environ for backend code."""
config = config or get_config(**config_kwargs)
updates = {}
for key, value in config.items():
+ if not str(key).startswith("SEARCH_BACKEND_"):
+ continue
if value is None:
continue
if isinstance(value, (str, int, float, bool, os.PathLike)):
updates[str(key)] = str(value)
- updates["DATA_DIR"] = str(config.DATA_DIR)
- updates["SNAP_DIR"] = str(config.USERS_DIR)
previous = {key: os.environ.get(key) for key in updates}
os.environ.update(updates)
try:
diff --git a/archivebox/search/query.py b/archivebox/search/query.py
index 15ce7091..2e01537d 100644
--- a/archivebox/search/query.py
+++ b/archivebox/search/query.py
@@ -248,10 +248,8 @@ def iter_query_search_ids(
backend = backends[backend_name]
try:
with search_backend_env(config=config):
- if hasattr(backend, "iter_search"):
+ if backend_name == "ripgrep":
ids = backend.iter_search(query, search_mode=search_mode_base)
- elif backend_name == "ripgrep":
- ids = backend.search(query, search_mode=search_mode_base)
else:
ids = backend.search(query)
for snapshot_id in ids:
diff --git a/archivebox/search/views.py b/archivebox/search/views.py
index ba868603..0c840ec8 100644
--- a/archivebox/search/views.py
+++ b/archivebox/search/views.py
@@ -101,7 +101,8 @@ def iter_admin_backend_search_ids(iterator, queryset):
def admin_snapshot_search_stream_view(model_admin, request):
"""Stream admin Snapshot search progress and cache matching IDs."""
query = (request.GET.get("q") or "").strip()
- search_mode = get_search_mode(request.GET.get("search_mode"), config=getattr(request, "archivebox_config", None))
+ config = request.archivebox_config
+ search_mode = get_search_mode(request.GET.get("search_mode"), config=config)
if not query:
return StreamingHttpResponse((), content_type="text/plain")
@@ -115,12 +116,12 @@ def admin_snapshot_search_stream_view(model_admin, request):
filter_request.path = target_url.path or request.path
filter_request.path_info = target_url.path or request.path_info
filter_request.GET = target_get
- filter_request.archivebox_config = getattr(request, "archivebox_config", None)
+ filter_request.archivebox_config = config
# Build the same filtered base queryset the changelist uses, but with the
# search params stripped. The stream intersects each wave with this queryset
# before writing IDs into the short-lived cache consumed by the changelist.
- current_request = getattr(model_admin, "request", None)
+ current_request = model_admin.__dict__.get("request")
try:
base_queryset = model_admin.get_changelist_instance(filter_request).queryset
finally:
@@ -150,12 +151,12 @@ def admin_snapshot_search_stream_view(model_admin, request):
nonlocal last_sent
iterator = None
try:
- search_mode_base = get_search_mode_base(search_mode, config=getattr(request, "archivebox_config", None))
+ search_mode_base = get_search_mode_base(search_mode, config=config)
iterator = (
iter_admin_meta_search_ids(query, base_queryset)
if search_mode_base == "meta"
else iter_admin_backend_search_ids(
- iter_query_search_ids(query, search_mode=search_mode, config=getattr(request, "archivebox_config", None)),
+ iter_query_search_ids(query, search_mode=search_mode, config=config),
base_queryset,
)
)
diff --git a/archivebox/services/archive_result_service.py b/archivebox/services/archive_result_service.py
index 3a7f9bb5..1a2cf798 100644
--- a/archivebox/services/archive_result_service.py
+++ b/archivebox/services/archive_result_service.py
@@ -5,7 +5,7 @@ import json
from collections import defaultdict
from collections.abc import Iterable
from pathlib import Path
-from typing import Any
+from typing import Any, Protocol, runtime_checkable
from asgiref.sync import sync_to_async
from django.db import IntegrityError
@@ -18,6 +18,11 @@ from abx_dl.services.base import BaseService
from .process_service import parse_event_datetime
+@runtime_checkable
+class ModelDumpable(Protocol):
+ def model_dump(self) -> dict[str, Any]: ...
+
+
def _collect_output_metadata(plugin_dir: Path) -> tuple[dict[str, dict], int, str]:
exclude_names = {"stdout.log", "stderr.log", "process.pid", "hook.pid", "listener.pid"}
output_files: dict[str, dict] = {}
@@ -97,15 +102,8 @@ def _normalize_output_files(raw_output_files: Any) -> dict[str, dict]:
if isinstance(item, str):
normalized[item] = _enrich_metadata(item, {})
continue
- if hasattr(item, "model_dump"):
+ if isinstance(item, ModelDumpable):
item = item.model_dump()
- elif hasattr(item, "path"):
- item = {
- "path": getattr(item, "path", ""),
- "extension": getattr(item, "extension", ""),
- "mimetype": getattr(item, "mimetype", ""),
- "size": getattr(item, "size", 0),
- }
if not isinstance(item, dict):
continue
path = str(item.get("path") or "").strip()
@@ -224,14 +222,18 @@ class ArchiveResultService(BaseService):
snapshot = await Snapshot.objects.filter(id=event.snapshot_id).select_related("crawl", "crawl__created_by").afirst()
if snapshot is None:
return
- plugin_dir = Path(snapshot.output_dir) / event.plugin
- output_files, output_size, output_mimetypes = await sync_to_async(_resolve_output_metadata)(event.output_files, plugin_dir)
process_started = await self.bus.find(
ProcessStartedEvent,
past=True,
future=False,
where=lambda candidate: self.bus.event_is_child_of(event, candidate),
)
+ plugin_dir = (
+ Path(process_started.output_dir)
+ if process_started is not None and process_started.output_dir
+ else Path(snapshot.output_dir) / event.plugin
+ )
+ output_files, output_size, output_mimetypes = await sync_to_async(_resolve_output_metadata)(event.output_files, plugin_dir)
process = None
if process_started is not None:
started_at = parse_event_datetime(process_started.start_ts)
@@ -292,14 +294,15 @@ class ArchiveResultService(BaseService):
update_fields = []
for field, value in defaults.items():
- if getattr(result, field) != value:
+ if result.__dict__[field] != value:
setattr(result, field, value)
update_fields.append(field)
if update_fields:
await result.asave(update_fields=[*update_fields, "modified_at"])
if result.status in (ArchiveResult.StatusChoices.SUCCEEDED, ArchiveResult.StatusChoices.NORESULTS):
- next_title = _extract_snapshot_title(str(snapshot.output_dir), event.plugin, result.output_str, snapshot_url=snapshot.url)
+ title_output_str = result.output_str if result.status == ArchiveResult.StatusChoices.SUCCEEDED else ""
+ next_title = _extract_snapshot_title(str(plugin_dir.parent), event.plugin, title_output_str, snapshot_url=snapshot.url)
if next_title and _should_update_snapshot_title(snapshot.title or "", next_title, snapshot_url=snapshot.url):
snapshot.title = next_title
await snapshot.asave(update_fields=["title", "modified_at"])
diff --git a/archivebox/services/machine_service.py b/archivebox/services/machine_service.py
index b91cd92e..f8a51567 100644
--- a/archivebox/services/machine_service.py
+++ b/archivebox/services/machine_service.py
@@ -40,19 +40,18 @@ class MachineService(BaseService):
self.bus.on(MachineEvent, self.on_MachineEvent__save_to_db)
async def on_MachineEvent__save_to_db(self, event: MachineEvent) -> None:
- from archivebox.machine.models import Machine, _sanitize_machine_config
- from archivebox.config.common import get_config
+ from archivebox.machine.models import Machine
if event.config_type != "derived":
return
machine = await sync_to_async(Machine.current, thread_sensitive=True)()
- lib_dir = await sync_to_async(lambda: get_config(include_machine=False).LIB_DIR, thread_sensitive=True)()
- config = dict(machine.config or {})
+ old_config = dict(machine.config or {})
+ config = dict(old_config)
if event.config is not None:
binary_only = _strip_to_binary_keys(event.config)
- config.update(_sanitize_machine_config(binary_only, lib_dir=lib_dir))
+ config.update(binary_only)
elif event.method == "update":
key = event.key.replace("config/", "", 1).strip()
if key and _is_binary_event_key(key):
@@ -64,5 +63,7 @@ class MachineService(BaseService):
else:
return
- machine.config = _sanitize_machine_config(config, lib_dir=lib_dir)
+ if config == old_config:
+ return
+ machine.config = config
await machine.asave(update_fields=["config", "modified_at"])
diff --git a/archivebox/services/runner.py b/archivebox/services/runner.py
index c4a8e8b2..8dcaf39e 100644
--- a/archivebox/services/runner.py
+++ b/archivebox/services/runner.py
@@ -2,15 +2,12 @@ from __future__ import annotations
import asyncio
import contextvars
-import json
import os
import signal
import shutil
-import subprocess
import sys
import threading
import time
-from collections.abc import Mapping
from contextlib import nullcontext
from datetime import timedelta
from functools import lru_cache
@@ -58,10 +55,10 @@ 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.common import ArchiveBoxBaseConfig
+from archivebox.config.common import ArchiveBoxBaseConfig, normalize_runtime_config
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
+from archivebox.core.shutdown_util import foreground_shutdown_signals, raise_if_shutdown_requested
from archivebox.search.sonic_daemon import register_sonic_daemon_event_handler
from archivebox.workers.models import ACTIVE_STATE_LEASE_SECONDS
@@ -74,6 +71,9 @@ from .snapshot_service import SnapshotService, finalize_completed_snapshot
from .tag_service import TagService
+QUEUED_PLUGIN_RESULT_BATCH_SIZE = 100
+
+
def _bus_name(prefix: str, identifier: str) -> str:
normalized = "".join(ch if ch.isalnum() else "_" for ch in identifier)
return f"{prefix}_{normalized}"
@@ -110,9 +110,9 @@ def _runner_console_line(*, crawl=None, crawl_id=None, snapshot=None, status: st
if snapshot is not None:
label = snapshot.url
else:
- label = (getattr(crawl, "label", "") or "").strip()
+ label = (crawl.label or "").strip()
if not label:
- label = (getattr(crawl, "urls", "") or "").partition("\n")[0].strip() or str(crawl_id)
+ label = (crawl.urls or "").partition("\n")[0].strip() or str(crawl_id)
line.append(_runner_label(label, reserve=prefix_width))
Console(highlight=False).print(line)
@@ -122,14 +122,6 @@ 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: 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:
context = contextvars.copy_context()
context.run(EventBus.current_event_context.set, None)
@@ -149,8 +141,9 @@ async def _emit_machine_config(
derived_config: dict[str, Any],
parent_event=None,
) -> None:
- user_config = _normalize_runtime_config(config)
- derived_machine_config = _normalize_runtime_config(derived_config)
+ user_config = normalize_runtime_config(config)
+ user_config["ABX_RUNTIME"] = "archivebox"
+ derived_machine_config = normalize_runtime_config(derived_config)
user_event = MachineEvent(
config=user_config,
config_type="user",
@@ -179,7 +172,6 @@ def ensure_background_runner(*, allow_under_pytest: bool = False) -> bool:
if os.environ.get("PYTEST_CURRENT_TEST") and not allow_under_pytest:
return False
- from archivebox.config import CONSTANTS
from archivebox.machine.models import Machine, Process
from archivebox.workers.supervisord_util import RUNNER_WORKER, get_existing_supervisord_process, get_worker, start_worker
@@ -205,22 +197,7 @@ def ensure_background_runner(*, allow_under_pytest: bool = False) -> bool:
except Exception:
pass
- log_path = CONSTANTS.LOGS_DIR / "errors.log"
- log_path.parent.mkdir(parents=True, exist_ok=True)
- env = os.environ.copy()
- env.setdefault("DATA_DIR", str(CONSTANTS.DATA_DIR))
-
- with log_path.open("a", encoding="utf-8") as log_handle:
- subprocess.Popen(
- [sys.executable, "-m", "archivebox", "run", "--daemon"],
- cwd=str(CONSTANTS.DATA_DIR),
- env=env,
- stdin=subprocess.DEVNULL,
- stdout=log_handle,
- stderr=log_handle,
- start_new_session=True,
- )
- return True
+ return False
class CrawlRunner:
@@ -233,6 +210,7 @@ class CrawlRunner:
process_discovered_snapshots_inline: bool = True,
show_progress: bool = True,
interactive_interrupts: bool = False,
+ config_overrides: dict[str, Any] | None = None,
):
self.crawl = crawl
self.bus = create_bus(name=_bus_name("ArchiveBox", str(crawl.id)), total_timeout=3600.0)
@@ -249,6 +227,7 @@ class CrawlRunner:
self.process_discovered_snapshots_inline = process_discovered_snapshots_inline
self.show_progress = show_progress
self.interactive_interrupts = interactive_interrupts
+ self.config_overrides = dict(config_overrides or {})
async def ignore_snapshot(_snapshot_id: str) -> None:
return None
@@ -265,7 +244,7 @@ class CrawlRunner:
self.snapshot_semaphore = asyncio.Semaphore(1)
self.max_concurrent_snapshots = 1
self.persona = None
- self.base_config: dict[str, Any] = {}
+ self.base_config: ArchiveBoxBaseConfig | dict[str, Any] = {}
self.derived_config: dict[str, Any] = {}
self.primary_url = ""
self.crawl_output_dir = ""
@@ -282,6 +261,8 @@ class CrawlRunner:
self._last_lease_heartbeat_at = 0.0
def _request_abort_from_signal(self, _sig: signal.Signals) -> None:
+ if os.environ.get("ARCHIVEBOX_RUNNER_DAEMON") == "1":
+ os._exit(128 + int(_sig))
already_requested = self._signal_abort_requested
self._signal_abort_requested = True
self._skip_wait_until_idle = True
@@ -352,16 +333,19 @@ class CrawlRunner:
if self.interactive_interrupts
else "\n[🛑] Got {signal_name}, stopping gracefully...\n"
)
- # interactive_interrupts is only enabled when this runner belongs
- # to a foreground `archivebox add`. Runners owned by server/update/
- # run should use immediate graceful shutdown instead of the
- # add-specific "abort current hook or continue" flow.
+ self._run_task = asyncio.current_task()
+ # Do not raise KeyboardInterrupt directly from an OS signal while
+ # the asyncio loop is active. Python can inject it into whichever
+ # task is currently running, which produces noisy "Task exception
+ # was never retrieved" logs from unrelated abxbus housekeeping
+ # tasks. _request_abort_from_signal() cancels the runner task
+ # cooperatively instead; repeated signals still hard-exit in the
+ # shared foreground signal handler.
with foreground_shutdown_signals(
first_signal_message=first_signal_message,
on_signal=self._request_abort_from_signal,
- raise_on_first_signal=not self.interactive_interrupts,
+ raise_on_first_signal=False,
):
- self._run_task = asyncio.current_task()
snapshot_ids = await sync_to_async(self.load_run_state, thread_sensitive=True)()
max_concurrent_snapshots = max(1, int(self.base_config.get("CRAWL_MAX_CONCURRENT_SNAPSHOTS", 1)))
self.max_concurrent_snapshots = max_concurrent_snapshots
@@ -372,10 +356,7 @@ class CrawlRunner:
await heartbeat.start()
await _emit_machine_config(
self.bus,
- config={
- **self.base_config,
- "ABX_RUNTIME": "archivebox",
- },
+ config=self.base_config,
derived_config=self.derived_config,
)
if snapshot_ids:
@@ -386,8 +367,9 @@ class CrawlRunner:
await heartbeat.stop()
await self.stop_snapshot_tasks()
try:
- if not self._skip_wait_until_idle:
- await self.bus.wait_until_idle(timeout=30.0)
+ await self.bus.wait_until_idle(timeout=1.0 if self._skip_wait_until_idle else 30.0)
+ except TimeoutError:
+ pass
finally:
await self.bus.destroy(clear=False)
bus_destroyed = True
@@ -548,7 +530,7 @@ class CrawlRunner:
return
await sync_to_async(self.crawl.refresh_from_db, thread_sensitive=True)()
- config = await sync_to_async(lambda: get_config(crawl=self.crawl, include_machine=False), thread_sensitive=True)()
+ config = await sync_to_async(lambda: get_config(crawl=self.crawl), thread_sensitive=True)()
self.max_concurrent_snapshots = max(1, int(config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"]))
active_snapshot_ids = [snapshot_id for snapshot_id, task in self.snapshot_tasks.items() if not task.done()]
@@ -573,20 +555,27 @@ class CrawlRunner:
from archivebox.config.common import get_config
from archivebox.core.models import Snapshot
from archivebox.plugins.hooks import discover_hooks
- from archivebox.machine.models import Machine, NetworkInterface, Process, _sanitize_machine_config
+ from archivebox.machine.models import Machine, NetworkInterface, Process
self.primary_url = self.crawl.get_urls_list()[0] if self.crawl.get_urls_list() else ""
- current_iface = NetworkInterface.current(refresh=True)
+ current_iface = NetworkInterface.current(refresh=not self.allow_maintenance_on_inactive_crawl)
current_process = Process.current()
if current_process.iface_id != current_iface.id or current_process.machine_id != current_iface.machine_id:
current_process.iface = current_iface
current_process.machine = current_iface.machine
current_process.save(update_fields=["iface", "machine", "modified_at"])
self.persona = self.crawl.resolve_persona()
- self.base_config = get_config(crawl=self.crawl, include_machine=False)
- self.derived_config = _sanitize_machine_config(Machine.current().config, lib_dir=self.base_config["LIB_DIR"])
+ self.base_config = get_config(crawl=self.crawl)
+ self.derived_config = dict(Machine.current().config or {})
self.crawl_output_dir = str(self.crawl.output_dir)
- self.base_config["ABX_RUNTIME"] = "archivebox"
+ if self.persona:
+ self.base_config.update(
+ self.persona.prepare_runtime_for_crawl(
+ self.crawl,
+ chrome_binary=self.base_config["CHROME_BINARY"],
+ ),
+ )
+ self.base_config.update(self.config_overrides)
if self.selected_plugins is None:
raw_plugins = str(self.base_config.get("PLUGINS") or "").strip()
if raw_plugins:
@@ -597,13 +586,6 @@ class CrawlRunner:
hook.parent.name for event_name in runtime_events for hook in discover_hooks(event_name, config=self.base_config)
}
self.selected_plugins = sorted(runtime_plugins) or None
- if self.persona:
- self.base_config.update(
- self.persona.prepare_runtime_for_crawl(
- self.crawl,
- chrome_binary=self.base_config["CHROME_BINARY"],
- ),
- )
if self.initial_snapshot_ids:
# Direct snapshot maintenance paths are allowed to name paused
# snapshots explicitly. The runner still requires selected_plugins
@@ -712,31 +694,32 @@ class CrawlRunner:
return live_ui
def load_snapshot_payload(self, snapshot_id: str) -> dict[str, Any]:
- from archivebox.core.models import Snapshot
from archivebox.config.common import get_config
+ from archivebox.core.models import Snapshot
- snapshot = Snapshot.objects.select_related("crawl").get(id=snapshot_id)
+ snapshot = Snapshot.objects.select_related("crawl", "crawl__created_by").get(id=snapshot_id)
+ self.crawl = snapshot.crawl
+ self.persona = snapshot.crawl.resolve_persona()
+ self.base_config = get_config(crawl=snapshot.crawl)
+ if self.persona:
+ self.base_config.update(
+ self.persona.prepare_runtime_for_crawl(
+ snapshot.crawl,
+ chrome_binary=self.base_config["CHROME_BINARY"],
+ ),
+ )
+ self.base_config.update(self.config_overrides)
+ self.crawl_output_dir = str(snapshot.crawl.output_dir)
runtime_chrome_overrides = {
key: self.base_config[key] for key in ("CHROME_USER_DATA_DIR", "CHROME_DOWNLOADS_DIR") if self.base_config.get(key)
}
- config = get_config(
+ config = self.base_config.for_crawl_runtime(
crawl=snapshot.crawl,
snapshot=snapshot,
- base_config=self.base_config or None,
- overrides=runtime_chrome_overrides,
- include_machine=False,
+ persona=self.persona,
+ runtime_overrides=runtime_chrome_overrides,
+ extra_context={"snapshot_id": str(snapshot.id), "snapshot_depth": snapshot.depth},
)
- config["CRAWL_DIR"] = self.crawl_output_dir
- config["SNAP_DIR"] = str(snapshot.output_dir)
- extra_context: dict[str, Any] = {}
- if config.get("EXTRA_CONTEXT"):
- parsed_extra_context = json.loads(str(config["EXTRA_CONTEXT"]))
- if not isinstance(parsed_extra_context, dict):
- raise TypeError("EXTRA_CONTEXT must decode to an object")
- extra_context = parsed_extra_context
- extra_context["snapshot_id"] = str(snapshot.id)
- extra_context["snapshot_depth"] = snapshot.depth
- config["EXTRA_CONTEXT"] = json.dumps(extra_context, separators=(",", ":"), sort_keys=True)
return {
"id": str(snapshot.id),
"url": snapshot.url,
@@ -748,7 +731,7 @@ class CrawlRunner:
"depth": snapshot.depth,
"status": snapshot.status,
"output_dir": str(snapshot.output_dir),
- "config": _normalize_runtime_config(config),
+ "config": normalize_runtime_config(config),
"_snapshot": snapshot,
}
@@ -774,9 +757,10 @@ class CrawlRunner:
if parent_snapshot is None:
return
config = await sync_to_async(
- lambda: get_config(crawl=self.crawl, snapshot=parent_snapshot, include_machine=False),
+ lambda: get_config(crawl=self.crawl, snapshot=parent_snapshot),
thread_sensitive=True,
)()
+ config = config.for_crawl_runtime(crawl=self.crawl, snapshot=parent_snapshot, persona=self.crawl.resolve_persona())
if CrawlLimitState.from_config(config).get_stop_reason() in ("crawl_max_size", "crawl_timeout"):
return
@@ -790,8 +774,8 @@ class CrawlRunner:
async def run_crawl(self, root_snapshot_id: str, snapshot_ids: list[str]) -> None:
snapshot = await sync_to_async(self.load_snapshot_payload, thread_sensitive=True)(root_snapshot_id)
- config = _normalize_runtime_config(snapshot["config"])
- derived_config = _normalize_runtime_config(self.derived_config)
+ config = normalize_runtime_config(snapshot["config"])
+ derived_config = normalize_runtime_config(self.derived_config)
output_dir = Path(self.crawl_output_dir)
plugins = self.runtime_plugins()
abx_snapshot = AbxSnapshot(
@@ -1033,8 +1017,8 @@ class CrawlRunner:
):
await sync_to_async(self.seal_snapshot_due_to_limit, thread_sensitive=True)(snapshot_id)
return
- config = _normalize_runtime_config(snapshot["config"])
- derived_config = _normalize_runtime_config(self.derived_config)
+ config = normalize_runtime_config(snapshot["config"])
+ derived_config = normalize_runtime_config(self.derived_config)
output_dir = Path(snapshot["output_dir"])
plugins = (
filter_plugins(self.plugins, snapshot_selected_plugins, include_providers=True)
@@ -1087,9 +1071,14 @@ class CrawlRunner:
# runner is the scheduler owner. Finalize idempotently here too
# so a completed snapshot cannot remain STARTED if the event was
# observed before its DB projector advanced the state machine.
- await sync_to_async(finalize_completed_snapshot, thread_sensitive=True)(snapshot_id)
+ crawl_limit_stop_reason = CrawlLimitState.from_config(config).get_stop_reason()
+ await sync_to_async(finalize_completed_snapshot, thread_sensitive=True)(
+ snapshot_id,
+ output_dir=output_dir,
+ crawl_limit_stop_reason=crawl_limit_stop_reason,
+ )
if snapshot["status"] == "sealed":
- await sync_to_async(run_snapshot_maintenance, thread_sensitive=True)(snapshot_id)
+ await sync_to_async(run_snapshot_maintenance, thread_sensitive=True)(snapshot_id, output_dir=output_dir)
return
await self.enqueue_discovered_snapshots_from_outputs(snapshot)
await sync_to_async(
@@ -1109,7 +1098,7 @@ class CrawlRunner:
def seal_snapshot_due_to_limit(self, snapshot_id: str) -> None:
from archivebox.core.models import Snapshot
- snapshot = Snapshot.objects.filter(id=snapshot_id).first()
+ snapshot = Snapshot.objects.select_related("crawl", "crawl__created_by").filter(id=snapshot_id).first()
if snapshot is None or snapshot.status == Snapshot.StatusChoices.SEALED:
return
if snapshot.status == Snapshot.StatusChoices.STARTED:
@@ -1129,6 +1118,7 @@ def run_crawl(
process_discovered_snapshots_inline: bool = True,
show_progress: bool = True,
interactive_interrupts: bool = False,
+ config_overrides: dict[str, Any] | None = None,
) -> None:
from archivebox.crawls.models import Crawl
from django.db import close_old_connections
@@ -1145,6 +1135,7 @@ def run_crawl(
process_discovered_snapshots_inline=process_discovered_snapshots_inline,
show_progress=show_progress,
interactive_interrupts=interactive_interrupts,
+ config_overrides=config_overrides,
).run(),
)
finally:
@@ -1171,15 +1162,15 @@ def run_crawl(
async def _run_binary(binary_id: str) -> None:
from archivebox.config.common import get_config
- from archivebox.machine.models import Binary, Machine, _sanitize_machine_config
+ from archivebox.machine.models import Binary, Machine
binary = await Binary.objects.aget(id=binary_id)
plugins = discover_plugins()
config = get_config(include_machine=False)
machine = await sync_to_async(Machine.current, thread_sensitive=True)()
- derived_config = _normalize_runtime_config(_sanitize_machine_config(machine.config, lib_dir=config["LIB_DIR"]))
- config["ABX_RUNTIME"] = "archivebox"
- config = _normalize_runtime_config(config)
+ derived_config = normalize_runtime_config(machine.config)
+ config = config.for_crawl()
+ config = normalize_runtime_config(config)
bus = create_bus(name=_bus_name("ArchiveBox_binary", str(binary.id)), total_timeout=1800.0)
process_service = PersistedProcessService(bus)
BinaryCacheService(bus, backend=ArchiveBoxDBBinaryCacheBackend())
@@ -1273,10 +1264,10 @@ def queued_plugins_for_snapshot(snapshot_id: str) -> list[str] | None:
return None
-def run_snapshot_maintenance(snapshot_id: str) -> bool:
+def run_snapshot_maintenance(snapshot_id: str, *, output_dir: Path | None = None) -> bool:
from archivebox.core.models import ArchiveResult, Snapshot
- snapshot = Snapshot.objects.filter(id=snapshot_id).first()
+ snapshot = Snapshot.objects.select_related("crawl", "crawl__created_by").filter(id=snapshot_id).first()
if snapshot is None:
return False
@@ -1290,9 +1281,23 @@ def run_snapshot_maintenance(snapshot_id: str) -> bool:
# branch can process those targeted plugin rows on the next tick
# This avoids reopening final/paused snapshots while also avoiding stranded
# queued ArchiveResults that have no independent scheduler.
- snapshot.retry_at = timezone.now() if has_queued_results else None
- snapshot.save(update_fields=["retry_at", "modified_at"])
- snapshot.write_index_jsonl()
+ current_retry_at = snapshot.retry_at
+ next_retry_at = timezone.now() if has_queued_results else None
+ snapshot.retry_at = next_retry_at
+ if snapshot.fs_migration_needed:
+ snapshot.save(update_fields=["retry_at", "modified_at"])
+ else:
+ updated = snapshot.safe_update(
+ {"retry_at": next_retry_at},
+ refresh=False,
+ extra_filter={
+ "status": snapshot.StatusChoices.SEALED,
+ "retry_at": current_retry_at,
+ },
+ )
+ if not updated:
+ return False
+ snapshot.write_index_jsonl(output_dir=output_dir)
return True
@@ -1395,8 +1400,6 @@ def run_due_snapshot(snapshot, *, lock_seconds: int, interactive_interrupts: boo
snapshot = Snapshot.objects.get(pk=snapshot.pk)
except Snapshot.DoesNotExist:
return False
- if runtime_config is not None:
- snapshot._runtime_config = runtime_config
parent_reconciled = snapshot.reconcile_parent_lifecycle(lock_seconds=lock_seconds)
if parent_reconciled is not None:
return parent_reconciled
@@ -1547,14 +1550,14 @@ def run_due_binary(binary, *, lock_seconds: int) -> bool:
async def _run_install(plugin_names: list[str] | None = None) -> None:
from archivebox.config.common import get_config
- from archivebox.machine.models import Machine, _sanitize_machine_config
+ from archivebox.machine.models import Machine
plugins = discover_plugins()
config = get_config(include_machine=False)
machine = await sync_to_async(Machine.current, thread_sensitive=True)()
- derived_config = _normalize_runtime_config(_sanitize_machine_config(machine.config, lib_dir=config["LIB_DIR"]))
- config["ABX_RUNTIME"] = "archivebox"
- config = _normalize_runtime_config(config)
+ derived_config = normalize_runtime_config(machine.config)
+ config = config.for_crawl()
+ config = normalize_runtime_config(config)
bus = create_bus(name="ArchiveBox_install", total_timeout=3600.0)
PersistedProcessService(bus)
BinaryCacheService(bus, backend=ArchiveBoxDBBinaryCacheBackend())
@@ -1721,24 +1724,84 @@ def _run_due_queued_plugin_result(
runtime_config,
) -> bool:
from archivebox.core.models import ArchiveResult, Snapshot
+ from django.db.models import Exists, OuterRef
if not plugin_names:
return False
- queued_results = ArchiveResult.objects.filter(
+ queued_snapshot_ids = ArchiveResult.objects.filter(
status=ArchiveResult.StatusChoices.QUEUED,
plugin__in=plugin_names,
- snapshot__status=Snapshot.StatusChoices.SEALED,
- snapshot__retry_at__lte=timezone.now(),
+ ).values("snapshot_id")
+ due_snapshots = Snapshot.objects.filter(
+ id__in=queued_snapshot_ids,
+ retry_at__lte=timezone.now(),
+ status=Snapshot.StatusChoices.SEALED,
)
if crawl_id:
- queued_results = queued_results.filter(snapshot__crawl_id=crawl_id)
- due_snapshot_id = queued_results.order_by("snapshot__retry_at", "snapshot__created_at").values_list("snapshot_id", flat=True).first()
- return _run_due_snapshot_id(
- due_snapshot_id,
- lock_seconds=lock_seconds,
- interactive_interrupts=interactive_interrupts,
- runtime_config=runtime_config,
+ due_snapshots = due_snapshots.filter(crawl_id=crawl_id)
+ due_snapshots = due_snapshots.only("id", "crawl_id", "retry_at", "status").order_by("retry_at", "created_at")
+ first_due_snapshot = due_snapshots.first()
+ if first_due_snapshot is None:
+ return False
+ root_crawl_id = str(first_due_snapshot.crawl_id)
+ batch_candidates = list(
+ due_snapshots.filter(crawl_id=root_crawl_id)[:QUEUED_PLUGIN_RESULT_BATCH_SIZE],
)
+ if not batch_candidates:
+ return False
+
+ selected_plugins: list[str] | None = None
+ claimed_snapshot_ids: list[str] = []
+ for snapshot in batch_candidates:
+ snapshot_selected_plugins = queued_plugins_for_snapshot(str(snapshot.id))
+ if not snapshot_selected_plugins:
+ continue
+ if selected_plugins is None:
+ selected_plugins = snapshot_selected_plugins
+ if snapshot_selected_plugins != selected_plugins:
+ continue
+ if not Snapshot.claim_for_worker(snapshot, lock_seconds=lock_seconds):
+ continue
+ snapshot.refresh_from_db()
+ snapshot.finalize_completed_upload_results()
+ if snapshot.fs_migration_needed:
+ run_snapshot_maintenance(str(snapshot.id))
+ snapshot.refresh_from_db()
+ if snapshot.status != Snapshot.StatusChoices.SEALED:
+ continue
+ claimed_snapshot_ids.append(str(snapshot.id))
+ _runner_console_line(crawl_id=snapshot.crawl_id, snapshot=snapshot)
+
+ if not claimed_snapshot_ids or selected_plugins is None:
+ return True
+
+ run_crawl(
+ root_crawl_id,
+ snapshot_ids=claimed_snapshot_ids,
+ selected_plugins=selected_plugins,
+ process_discovered_snapshots_inline=True,
+ interactive_interrupts=interactive_interrupts,
+ config_overrides={
+ "CRAWL_MAX_CONCURRENT_SNAPSHOTS": QUEUED_PLUGIN_RESULT_BATCH_SIZE,
+ },
+ )
+ if all(plugin.startswith("search_backend_") for plugin in selected_plugins):
+ queued_results = ArchiveResult.objects.filter(
+ snapshot_id=OuterRef("pk"),
+ status=ArchiveResult.StatusChoices.QUEUED,
+ )
+ Snapshot.objects.filter(
+ id__in=claimed_snapshot_ids,
+ status=Snapshot.StatusChoices.SEALED,
+ ).annotate(
+ has_queued_results=Exists(queued_results),
+ ).filter(
+ has_queued_results=False,
+ ).update(
+ retry_at=None,
+ modified_at=timezone.now(),
+ )
+ return True
def _run_due_binary() -> bool:
@@ -1836,6 +1899,7 @@ def run_pending_crawls(
analyze_sweep_started_at = 0.0
orchestrator_started_at = time.monotonic()
while True:
+ raise_if_shutdown_requested()
now_monotonic = time.monotonic()
if now_monotonic - last_retention_at >= (60.0 if daemon else 1.0):
for model in (ArchiveResult, Snapshot, Crawl, Process):
@@ -1867,29 +1931,6 @@ def run_pending_crawls(
if _fast_forward_same_path_snapshot_fs_versions():
continue
- # Final-state snapshot maintenance comes before normal crawl work:
- # filesystem/index maintenance and upload finalization should drain
- # promptly, but pure search backend backfills are deferred below so
- # they do not starve live crawls.
- sealed_snapshots = Snapshot.objects.filter(
- retry_at__lte=timezone.now(),
- status=Snapshot.StatusChoices.SEALED,
- )
- if search_plugin_names:
- sealed_snapshots = sealed_snapshots.exclude(
- archiveresult__status=ArchiveResult.StatusChoices.QUEUED,
- archiveresult__plugin__in=search_plugin_names,
- )
- if crawl_id:
- sealed_snapshots = sealed_snapshots.filter(crawl_id=crawl_id)
- if _run_due_snapshot_query(
- sealed_snapshots,
- lock_seconds=60,
- interactive_interrupts=interactive_interrupts,
- runtime_config=runtime_config,
- ):
- continue
-
if not maintenance_only:
active_snapshots = Snapshot.objects.filter(
retry_at__lte=timezone.now(),
@@ -1987,6 +2028,32 @@ def run_pending_crawls(
):
continue
+ # Broad final-state maintenance is intentionally a fallback. Specific
+ # queued plugin work above can use ArchiveResult's scheduler indexes;
+ # this branch may need to prove that no due sealed snapshot remains, so
+ # avoid paying that scan while targeted work is already available.
+ sealed_snapshots = Snapshot.objects.filter(
+ retry_at__lte=timezone.now(),
+ status=Snapshot.StatusChoices.SEALED,
+ )
+ if search_plugin_names:
+ queued_search_snapshot_ids = ArchiveResult.objects.filter(
+ status=ArchiveResult.StatusChoices.QUEUED,
+ plugin__in=search_plugin_names,
+ ).values("snapshot_id")
+ sealed_snapshots = sealed_snapshots.exclude(
+ id__in=queued_search_snapshot_ids,
+ )
+ if crawl_id:
+ sealed_snapshots = sealed_snapshots.filter(crawl_id=crawl_id)
+ if _run_due_snapshot_query(
+ sealed_snapshots,
+ lock_seconds=60,
+ interactive_interrupts=interactive_interrupts,
+ runtime_config=runtime_config,
+ ):
+ continue
+
if not maintenance_only:
if _run_due_crawl_status(
Crawl.StatusChoices.SEALED,
diff --git a/archivebox/services/snapshot_service.py b/archivebox/services/snapshot_service.py
index a548adb7..8ae63c82 100644
--- a/archivebox/services/snapshot_service.py
+++ b/archivebox/services/snapshot_service.py
@@ -11,7 +11,12 @@ from abx_dl.limits import CrawlLimitState
from abx_dl.services.base import BaseService
-def finalize_completed_snapshot(snapshot_id: str) -> None:
+def finalize_completed_snapshot(
+ snapshot_id: str,
+ *,
+ output_dir=None,
+ crawl_limit_stop_reason: str | None = None,
+) -> None:
from archivebox.core.models import Snapshot
snapshot = Snapshot.objects.select_related("crawl", "crawl__created_by").filter(id=snapshot_id).first()
@@ -22,7 +27,7 @@ def finalize_completed_snapshot(snapshot_id: str) -> None:
snapshot.downloaded_at = timezone.now()
snapshot.save(update_fields=["downloaded_at", "modified_at"])
- stop_reason = _crawl_limit_stop_reason(snapshot.crawl)
+ stop_reason = crawl_limit_stop_reason if crawl_limit_stop_reason is not None else _crawl_limit_stop_reason(snapshot.crawl)
if snapshot.crawl_id and stop_reason in ("crawl_max_size", "crawl_timeout"):
Snapshot.objects.filter(
crawl_id=snapshot.crawl_id,
@@ -40,14 +45,17 @@ def finalize_completed_snapshot(snapshot_id: str) -> None:
snapshot.sm.seal()
snapshot.refresh_from_db()
- snapshot.write_index_jsonl()
+ snapshot.write_index_jsonl(output_dir=output_dir)
def _crawl_limit_stop_reason(crawl) -> str:
from archivebox.config.common import get_config
- config = get_config(crawl=crawl, include_machine=False)
- config["CRAWL_DIR"] = str(crawl.output_dir)
+ config_model = get_config(crawl=crawl)
+ config = config_model.for_crawl_runtime(
+ crawl=crawl,
+ persona=crawl.resolve_persona(),
+ )
return CrawlLimitState.from_config(config).get_stop_reason()
diff --git a/archivebox/templates/core/add.html b/archivebox/templates/core/add.html
index 6f54dcdb..086fc5d6 100644
--- a/archivebox/templates/core/add.html
+++ b/archivebox/templates/core/add.html
@@ -304,7 +304,7 @@
{{ form.config.errors }}
{% endif %}
- Override any config option for this crawl (e.g., TIMEOUT, USER_AGENT, CHROME_BINARY, etc.). URL_ALLOWLIST, URL_DENYLIST, and PLUGINS are updated automatically from the fields above.
+ Override crawl-scoped config options (e.g., TIMEOUT, USER_AGENT, URL_ALLOWLIST, URL_DENYLIST, PLUGINS). *_BINARY paths are managed on Persona or Machine config.
diff --git a/archivebox/tests/conftest.py b/archivebox/tests/conftest.py
index c047a7a8..4f8ab00f 100644
--- a/archivebox/tests/conftest.py
+++ b/archivebox/tests/conftest.py
@@ -25,19 +25,10 @@ pytest_plugins = ["archivebox.tests.fixtures"]
REPO_ROOT = Path(__file__).resolve().parents[2]
PYTEST_BASETEMP_ROOT = (REPO_ROOT / "tests" / "out").resolve()
-SESSION_DATA_DIR = Path(
- os.environ.get("ARCHIVEBOX_PYTEST_SESSION_DATA_DIR") or tempfile.mkdtemp(prefix="archivebox-pytest-session-"),
-).resolve()
-# Force ArchiveBox imports to see a temp DATA_DIR during test collection.
-os.environ["ARCHIVEBOX_PYTEST_SESSION_DATA_DIR"] = str(SESSION_DATA_DIR)
-os.environ["DATA_DIR"] = str(SESSION_DATA_DIR)
+SESSION_DATA_DIR = Path(tempfile.mkdtemp(prefix="archivebox-pytest-session-")).resolve()
(SESSION_DATA_DIR / "tests").mkdir(parents=True, exist_ok=True)
os.chdir(SESSION_DATA_DIR)
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "archivebox.core.settings")
-os.environ.pop("ARCHIVE_DIR", None)
-os.environ.pop("USERS_DIR", None)
-os.environ.pop("CRAWL_DIR", None)
-os.environ.pop("SNAP_DIR", None)
def _is_repo_path(path: Path) -> bool:
@@ -56,7 +47,7 @@ def _assert_safe_runtime_paths(*, cwd: Path | None = None, env: dict[str, str] |
if cwd is not None:
_assert_not_repo_path(cwd, label="cwd")
- for key in ("DATA_DIR", "ARCHIVE_DIR", "USERS_DIR", "CRAWL_DIR", "SNAP_DIR"):
+ for key in ("CRAWL_DIR", "SNAP_DIR"):
value = (env or {}).get(key)
if value:
_assert_not_repo_path(Path(value), label=key)
@@ -89,9 +80,8 @@ def run_archivebox_cmd(
"""
cmd = [sys.executable, "-m", "archivebox"] + args
- _assert_not_repo_path(data_dir, label="DATA_DIR")
+ _assert_not_repo_path(data_dir, label="cwd")
base_env = os.environ.copy()
- base_env["DATA_DIR"] = str(data_dir)
base_env["USE_COLOR"] = "False"
base_env["SHOW_PROGRESS"] = "False"
# Disable slow extractors for faster tests
@@ -143,9 +133,8 @@ def isolate_test_runtime(tmp_path, monkeypatch):
contract is that every test starts in its own temp directory and any
in-process ``os.environ`` edits are rolled back afterwards.
- Each in-process test gets an explicit temp ``DATA_DIR`` so ArchiveBox code
- never falls back to the repo cwd. Subprocess helpers that intentionally test
- cwd-based behavior remove ``DATA_DIR`` for the child process themselves.
+ ArchiveBox derives DATA_DIR from cwd, so subprocess helpers pass the target
+ collection as cwd instead of using DATA_DIR as an override.
"""
_assert_not_repo_path(tmp_path, label="tmp_path")
original_cwd = Path.cwd()
@@ -176,11 +165,6 @@ def isolate_test_runtime(tmp_path, monkeypatch):
monkeypatch.setattr(os, "chdir", guarded_chdir)
monkeypatch.setattr(subprocess, "Popen", guarded_popen)
- os.environ["DATA_DIR"] = str(tmp_path)
- os.environ.pop("ARCHIVE_DIR", None)
- os.environ.pop("USERS_DIR", None)
- os.environ.pop("CRAWL_DIR", None)
- os.environ.pop("SNAP_DIR", None)
reset_machine_model_caches()
try:
_assert_safe_runtime_paths(cwd=Path.cwd(), env=os.environ)
@@ -330,11 +314,6 @@ def run_archivebox_cmd_cwd(
_assert_not_repo_path(cwd, label="cwd")
base_env = os.environ.copy()
- base_env.pop("DATA_DIR", None)
- base_env.pop("ARCHIVE_DIR", None)
- base_env.pop("USERS_DIR", None)
- base_env.pop("CRAWL_DIR", None)
- base_env.pop("SNAP_DIR", None)
base_env["USE_COLOR"] = "False"
base_env["SHOW_PROGRESS"] = "False"
@@ -437,11 +416,6 @@ def run_python_cwd(
) -> tuple[str, str, int]:
_assert_not_repo_path(cwd, label="cwd")
base_env = os.environ.copy()
- base_env.pop("DATA_DIR", None)
- base_env.pop("ARCHIVE_DIR", None)
- base_env.pop("USERS_DIR", None)
- base_env.pop("CRAWL_DIR", None)
- base_env.pop("SNAP_DIR", None)
_assert_safe_runtime_paths(cwd=cwd, env=base_env)
result = subprocess.run(
[sys.executable, "-"],
@@ -473,7 +447,6 @@ def init_archive(cwd: Path) -> None:
def build_test_env(port: int, **extra: str) -> dict[str, str]:
env = os.environ.copy()
- env.pop("DATA_DIR", None)
env.update(
{
"PLUGINS": "wget",
@@ -874,7 +847,7 @@ def real_archive_with_example(tmp_path_factory, request):
Uses cwd for DATA_DIR.
"""
tmp_path = tmp_path_factory.mktemp("archivebox_data")
- if getattr(request, "cls", None) is not None:
+ if request.cls is not None:
request.cls.data_dir = tmp_path
stdout, stderr, returncode = run_archivebox_cmd_cwd(
diff --git a/archivebox/tests/migrations_helpers.py b/archivebox/tests/migrations_helpers.py
index 42d2a6c0..7dae8302 100644
--- a/archivebox/tests/migrations_helpers.py
+++ b/archivebox/tests/migrations_helpers.py
@@ -1023,7 +1023,6 @@ def seed_0_8_data(db_path: Path) -> dict[str, list[dict]]:
def run_archivebox(data_dir: Path, args: list, timeout: int = 60, env: dict | None = None) -> subprocess.CompletedProcess:
"""Run archivebox command in subprocess with given data directory."""
base_env = os.environ.copy()
- base_env["DATA_DIR"] = str(data_dir)
base_env["USE_COLOR"] = "False"
base_env["SHOW_PROGRESS"] = "False"
# Disable ALL extractors for faster tests (can be overridden by env parameter)
diff --git a/archivebox/tests/test_archive_result_service.py b/archivebox/tests/test_archive_result_service.py
index cc2f8585..30d68419 100644
--- a/archivebox/tests/test_archive_result_service.py
+++ b/archivebox/tests/test_archive_result_service.py
@@ -22,15 +22,19 @@ def _create_snapshot():
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
- crawl = Crawl.objects.create(
+ crawl = Crawl(
urls="https://example.com",
created_by_id=get_or_create_system_user_pk(),
)
- return Snapshot.objects.create(
+ crawl.save()
+
+ snapshot = Snapshot(
url="https://example.com",
crawl=crawl,
status=Snapshot.StatusChoices.STARTED,
)
+ snapshot.save()
+ return snapshot
def test_process_completed_projects_inline_archiveresult():
@@ -236,6 +240,42 @@ def test_snapshot_resolved_title_ignores_failed_title_output_str():
_cleanup_machine_process_rows()
+def test_snapshot_title_ignores_noresults_title_output_str():
+ from archivebox.core.models import ArchiveResult
+ from archivebox.services.archive_result_service import ArchiveResultService
+ import asyncio
+
+ snapshot = _create_snapshot()
+ plugin_dir = Path(snapshot.output_dir) / "title"
+ plugin_dir.mkdir(parents=True, exist_ok=True)
+
+ bus = create_bus(name="test_noresults_title_does_not_update_snapshot")
+ service = ArchiveResultService(bus)
+
+ event = ArchiveResultEvent(
+ snapshot_id=str(snapshot.id),
+ plugin="title",
+ hook_name="on_Snapshot__54_title.js",
+ status="noresults",
+ output_str="TimeoutError: Navigation timeout of 54172 ms exceeded",
+ start_ts="2026-03-22T12:00:00+00:00",
+ end_ts="2026-03-22T12:00:01+00:00",
+ )
+
+ async def emit_event() -> None:
+ await service.on_ArchiveResultEvent__save_to_db(event)
+
+ asyncio.run(emit_event())
+
+ result = ArchiveResult.objects.get(snapshot=snapshot, plugin="title", hook_name="on_Snapshot__54_title.js")
+ assert result.status == ArchiveResult.StatusChoices.NORESULTS
+ assert result.output_str == "TimeoutError: Navigation timeout of 54172 ms exceeded"
+ snapshot.refresh_from_db()
+ assert snapshot.title in (None, "")
+ assert snapshot.resolved_title == ""
+ _cleanup_machine_process_rows()
+
+
def test_snapshot_save_normalizes_url_title_to_none():
from archivebox.core.models import Snapshot
diff --git a/archivebox/tests/test_auth_ldap.py b/archivebox/tests/test_auth_ldap.py
index 28310248..c0c92513 100644
--- a/archivebox/tests/test_auth_ldap.py
+++ b/archivebox/tests/test_auth_ldap.py
@@ -117,14 +117,14 @@ class TestLDAPAuthBackend:
"""Test that ArchiveBoxLDAPBackend class is defined."""
from archivebox.ldap.auth import ArchiveBoxLDAPBackend
- assert hasattr(ArchiveBoxLDAPBackend, "authenticate_ldap_user")
+ assert ArchiveBoxLDAPBackend.authenticate_ldap_user is not None
def test_ldap_backend_inherits_correctly(self):
"""Test that ArchiveBoxLDAPBackend has correct inheritance."""
from archivebox.ldap.auth import ArchiveBoxLDAPBackend
# Should have authenticate_ldap_user method (from base or overridden)
- assert callable(getattr(ArchiveBoxLDAPBackend, "authenticate_ldap_user", None))
+ assert callable(ArchiveBoxLDAPBackend.authenticate_ldap_user)
class TestArchiveBoxWithLDAP:
diff --git a/archivebox/tests/test_cli_add.py b/archivebox/tests/test_cli_add.py
index d25f2471..cb6db41c 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["ACTIVE_PERSONA"] == "Default"
+ assert "ACTIVE_PERSONA" not in crawl.config
assert (tmp_path / "personas" / "Default" / "chrome_profile").is_dir()
diff --git a/archivebox/tests/test_cli_init.py b/archivebox/tests/test_cli_init.py
index 1cab73a6..89f9db6c 100644
--- a/archivebox/tests/test_cli_init.py
+++ b/archivebox/tests/test_cli_init.py
@@ -46,21 +46,15 @@ def test_init_creates_archive_directory(tmp_path):
assert archive_dir.is_dir()
-def test_init_respects_configured_archive_and_users_dirs(tmp_path):
- """Test that init creates configured archive/users storage roots."""
+def test_init_uses_cwd_archive_and_users_dirs(tmp_path):
+ """Test that init creates archive/users storage roots under cwd."""
os.chdir(tmp_path)
- archive_dir = tmp_path / "mounted_archive"
- users_dir = archive_dir / "custom_users"
- env = os.environ.copy()
- env["ARCHIVE_DIR"] = str(archive_dir)
- env["USERS_DIR"] = str(users_dir)
- result = subprocess.run(["archivebox", "init"], env=env, capture_output=True)
+ result = subprocess.run(["archivebox", "init"], capture_output=True)
assert result.returncode == 0
- assert archive_dir.is_dir()
- assert users_dir.is_dir()
- assert not (tmp_path / "archive").exists()
+ assert (tmp_path / "archive").is_dir()
+ assert (tmp_path / "archive" / "users").is_dir()
def test_init_creates_sources_directory(tmp_path):
diff --git a/archivebox/tests/test_cli_run.py b/archivebox/tests/test_cli_run.py
index 6394b182..5067236a 100644
--- a/archivebox/tests/test_cli_run.py
+++ b/archivebox/tests/test_cli_run.py
@@ -402,7 +402,7 @@ class TestRunDaemonMode:
stdout = proc.stdout.read()
stderr = proc.stderr.read()
- assert proc.returncode == 0, stdout + stderr
+ assert proc.returncode == 143, stdout + stderr
assert "No records to process" not in stderr
def test_run_daemon_takeover_has_single_active_runner_gate(self, initialized_archive, db):
diff --git a/archivebox/tests/test_cli_server.py b/archivebox/tests/test_cli_server.py
index 9a9b13e8..8d2dd002 100644
--- a/archivebox/tests/test_cli_server.py
+++ b/archivebox/tests/test_cli_server.py
@@ -11,6 +11,7 @@ import signal
import socket
import subprocess
import sys
+import time
from datetime import datetime
from types import SimpleNamespace
@@ -128,6 +129,7 @@ def test_runner_worker_uses_current_interpreter():
from archivebox.workers.supervisord_util import RUNNER_WORKER
assert RUNNER_WORKER["command"] == f"{sys.executable} -m archivebox run --daemon"
+ assert 'ARCHIVEBOX_RUNNER_DAEMON="1"' in RUNNER_WORKER["environment"]
def test_daphne_worker_uses_default_application_close_timeout():
@@ -175,6 +177,28 @@ def test_server_daemon_starts_real_plugin_owned_sonic_worker(archivebox_daemon_s
assert "sonic" in state["worker_sonic"]["name"]
+def test_server_daemon_restarts_runner_killed_by_signal(archivebox_daemon_server):
+ server = archivebox_daemon_server(
+ SEARCH_BACKEND_ENGINE="sqlite",
+ )
+ state = server.wait_for_workers(("worker_daphne", "worker_runner"))
+ old_runner_pid = state["worker_runner"]["pid"]
+
+ os.kill(old_runner_pid, signal.SIGTERM)
+
+ deadline = time.time() + 30
+ while time.time() < deadline:
+ state = server.worker_state()
+ runner = state.get("worker_runner", {})
+ if runner.get("statename") == "RUNNING" and runner.get("pid") and runner.get("pid") != old_runner_pid:
+ break
+ time.sleep(0.5)
+ else:
+ raise AssertionError(f"worker_runner did not restart after SIGTERM: {state}")
+
+ assert state["worker_daphne"]["statename"] == "RUNNING", state
+
+
def test_sonic_worker_is_disabled_when_sonic_disabled_and_engine_not_sonic(tmp_path):
from archivebox.workers.supervisord_util import get_sonic_supervisord_worker_from_plugin
diff --git a/archivebox/tests/test_crawl_runner.py b/archivebox/tests/test_crawl_runner.py
index cd28c32c..0e7e5f7b 100644
--- a/archivebox/tests/test_crawl_runner.py
+++ b/archivebox/tests/test_crawl_runner.py
@@ -129,6 +129,7 @@ def test_snapshot_payload_uses_crawl_persona_runtime_dirs():
assert Path(config["CHROME_DOWNLOADS_DIR"]).is_relative_to(crawl.output_dir)
assert Path(config["CHROME_USER_DATA_DIR"]).name == "chrome_profile"
assert Path(config["CHROME_DOWNLOADS_DIR"]).name == "chrome_downloads"
+ assert config["ACTIVE_PERSONA"] == "RuntimePersona"
assert Path(config["CRAWL_DIR"]) == crawl.output_dir
assert Path(config["SNAP_DIR"]) == snapshot.output_dir
@@ -163,6 +164,18 @@ def test_ensure_background_runner_skips_with_real_running_orchestrator_record():
assert process.status == Process.StatusChoices.RUNNING
+@pytest.mark.django_db(transaction=True)
+def test_ensure_background_runner_does_not_spawn_runner_without_supervisord():
+ from archivebox.services.runner import ensure_background_runner
+ from archivebox.workers.supervisord_util import get_existing_supervisord_process, stop_existing_supervisord_process
+
+ stop_existing_supervisord_process()
+ assert get_existing_supervisord_process(quiet=True) is None
+
+ assert ensure_background_runner(allow_under_pytest=True) is False
+ assert get_existing_supervisord_process(quiet=True) is None
+
+
def test_runner_task_context_clears_inherited_abxbus_handler_context(tmp_path):
from abx_dl.events import CrawlEvent, MachineEvent
from abx_dl.orchestrator import create_bus
@@ -174,7 +187,7 @@ def test_runner_task_context_clears_inherited_abxbus_handler_context(tmp_path):
async def emit_from_runner_task():
observations.append(("in_handler_context", in_handler_context()))
- machine_event = bus.emit(MachineEvent(config={"ABX_RUNTIME": "archivebox"}, config_type="user"))
+ machine_event = bus.emit(MachineEvent(config={"TIMEOUT": "30"}, config_type="user"))
await machine_event.now()
observations.append(("machine_event_path", bool(machine_event.event_path)))
@@ -405,7 +418,6 @@ def test_machine_service_persists_only_derived_config_events(tmp_path, hermetic_
config={
"CHROME_ISOLATION": "snapshot",
"CHROME_USER_DATA_DIR": "/tmp/stale-profile",
- "ABX_RUNTIME": "archivebox",
},
config_type="user",
),
@@ -569,7 +581,7 @@ def test_crawl_runner_empty_plugin_selection_emits_lifecycle_and_seals_crawl(tmp
assert runner.bus.event_is_child_of(completed_events[0], crawl_events[0])
assert runner.bus.event_is_child_of(snapshot_events[0], start_events[0])
assert runner.bus.event_is_child_of(snapshot_completed_events[0], snapshot_events[0])
- assert any(event.config_type == "user" and event.config.get("ABX_RUNTIME") == "archivebox" for event in machine_events)
+ assert any(event.config_type == "user" for event in machine_events)
crawl.refresh_from_db()
snapshot = Snapshot.objects.get(crawl=crawl)
diff --git a/archivebox/tests/test_frozen_crawl_config.py b/archivebox/tests/test_frozen_crawl_config.py
index 96ad2378..33792991 100644
--- a/archivebox/tests/test_frozen_crawl_config.py
+++ b/archivebox/tests/test_frozen_crawl_config.py
@@ -1,3 +1,6 @@
+import time
+from types import SimpleNamespace
+
import pytest
from django.test import RequestFactory
from django.utils import timezone
@@ -65,6 +68,8 @@ def test_crawl_save_freezes_full_raw_persona_config_and_redacts_public_serializa
assert crawl.config["USER_AGENT"] == "Frozen UA"
assert crawl.config["TWOCAPTCHA_API_KEY"] == SENSITIVE_SECRET
assert crawl.config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] == 3
+ assert "ACTIVE_PERSONA" not in crawl.config
+ assert "DEFAULT_PERSONA" not in crawl.config
assert "CRAWL_DIR" not in crawl.config
assert "SNAP_DIR" not in crawl.config
assert "DEBUG" not in crawl.config
@@ -82,9 +87,10 @@ def test_crawl_save_freezes_full_raw_persona_config_and_redacts_public_serializa
redacted_runtime_config = get_config(crawl=crawl, redact_sensitive=True)
assert redacted_runtime_config.USER_AGENT == "Frozen UA"
assert redacted_runtime_config.TWOCAPTCHA_API_KEY == SENSITIVE_CONFIG_VALUE_REDACTED
- execution_config = runtime_config.for_crawl_execution()
+ execution_config = runtime_config.for_crawl()
assert execution_config["DEBUG"] is False
- assert execution_config["CRAWL_DIR"] == str(crawl.output_dir)
+ assert "CRAWL_DIR" not in execution_config
+ assert "SNAP_DIR" not in execution_config
assert "SECRET_KEY" not in execution_config
assert "PUBLIC_ADD_VIEW" not in execution_config
assert "DATABASE_NAME" not in execution_config
@@ -123,11 +129,110 @@ def test_config_scopes_are_derived_from_section_and_field_metadata():
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("DEFAULT_PERSONA") == "crawl_execution"
+ assert ArchiveBoxConfig.scope_for_key("WGET_ENABLED") == "crawl_execution"
+ assert ArchiveBoxConfig.scope_for_key("WGET_WARC_ENABLED") == "crawl_frozen"
assert ArchiveBoxConfig.scope_for_key("SECRET_KEY") == "server"
assert ArchiveBoxConfig.scope_for_key("DATABASE_NAME") == "server"
+def test_plugin_selection_enabled_keys_are_derived_from_plugins_not_frozen_or_env_overridden(archivebox_db, monkeypatch):
+ from archivebox.config.common import get_config
+ from archivebox.crawls.models import Crawl
+ from archivebox.plugins.discovery import get_plugin_special_config
+
+ monkeypatch.setenv("ARCHIVEDOTORG_ENABLED", "False")
+ user = _user("frozen-config-enabled-admin")
+ persona = _persona(user, name="Enabled Persona")
+
+ env_default_crawl = Crawl(
+ urls="https://example.com/env-enabled",
+ persona=persona,
+ created_by=user,
+ config={},
+ status=Crawl.StatusChoices.QUEUED,
+ retry_at=timezone.now(),
+ )
+ env_default_crawl.save()
+ env_default_config = get_config(crawl=env_default_crawl, include_machine=False)
+ assert env_default_config.ARCHIVEDOTORG_ENABLED is False
+
+ crawl = Crawl(
+ urls="https://example.com/enabled",
+ persona=persona,
+ created_by=user,
+ config={"PLUGINS": "archivedotorg", "ARCHIVEDOTORG_ENABLED": True, "DEFAULT_PERSONA": "Other"},
+ status=Crawl.StatusChoices.QUEUED,
+ retry_at=timezone.now(),
+ )
+ crawl.save()
+
+ assert crawl.config["PLUGINS"] == "archivedotorg"
+ assert "ARCHIVEDOTORG_ENABLED" not in crawl.config
+ assert "DEFAULT_PERSONA" not in crawl.config
+ runtime_config = get_config(crawl=crawl, include_machine=False)
+ assert runtime_config.ARCHIVEDOTORG_ENABLED is True
+ assert runtime_config.WGET_ENABLED is False
+ assert get_plugin_special_config("archivedotorg", runtime_config)["enabled"] is True
+ assert get_plugin_special_config("wget", runtime_config)["enabled"] is False
+
+ monkeypatch.delenv("ARCHIVEDOTORG_ENABLED")
+
+ Crawl.objects.filter(id=crawl.id).update(
+ config={
+ **crawl.config,
+ "ARCHIVEDOTORG_ENABLED": False,
+ "YTDLP_ENABLED": True,
+ "WGET_ENABLED": True,
+ },
+ )
+ crawl.refresh_from_db()
+ stale_runtime_config = get_config(crawl=crawl, include_machine=False)
+ assert stale_runtime_config.ARCHIVEDOTORG_ENABLED is True
+ assert stale_runtime_config.YTDLP_ENABLED is False
+ assert stale_runtime_config.WGET_ENABLED is False
+
+
+def test_crawl_config_projections_stay_under_hot_path_budget():
+ from archivebox.config.common import ArchiveBoxConfig
+
+ config = ArchiveBoxConfig(TIMEOUT=12, USER_AGENT="Perf UA", CHROME_BINARY="perf-chrome")
+ persona = SimpleNamespace(
+ config={"USER_AGENT": "Persona UA"},
+ get_derived_config=lambda: {
+ "ACTIVE_PERSONA": "Perf Persona",
+ "USER_AGENT": "Persona UA",
+ "CHROME_USER_DATA_DIR": "/tmp/persona/chrome",
+ },
+ )
+ crawl = SimpleNamespace(config={"TIMEOUT": 13, "CHROME_BINARY": "crawl-chrome"})
+ snapshot = SimpleNamespace(config={"TIMEOUT": 14, "CHROME_BINARY": "snapshot-chrome"})
+ runtime_kwargs = {
+ "crawl": crawl,
+ "snapshot": snapshot,
+ "persona": persona,
+ "crawl_output_dir": "/tmp/archivebox/crawls/perf",
+ "snapshot_output_dir": "/tmp/archivebox/crawls/perf/snapshots/example",
+ "extra_context": {"snapshot_id": "perf"},
+ }
+
+ methods = {
+ "for_crawl": config.for_crawl,
+ "for_crawl_frozen": lambda: config.for_crawl_frozen(persona=persona),
+ "for_crawl_runtime": lambda: config.for_crawl_runtime(**runtime_kwargs),
+ }
+ iterations = 250
+ max_average_seconds = 0.020
+
+ for name, method in methods.items():
+ method()
+ started_at = time.perf_counter()
+ for _ in range(iterations):
+ method()
+ average_seconds = (time.perf_counter() - started_at) / iterations
+ assert average_seconds < max_average_seconds, f"{name} averaged {average_seconds * 1000:.3f}ms"
+
+
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
@@ -201,3 +306,39 @@ def test_schedule_enqueue_refreezes_using_current_template_persona_defaults(arch
assert "PUBLIC_ADD_VIEW" not in child.config
assert template.config["USER_AGENT"] == "Initial schedule UA"
assert template.config["TWOCAPTCHA_API_KEY"] == SENSITIVE_SECRET
+
+
+def test_crawl_config_backfill_migration_uses_frozen_config_helper(archivebox_db):
+ import importlib
+
+ from django.apps import apps
+ from django.db import connection
+
+ from archivebox.crawls.models import Crawl
+
+ migration = importlib.import_module("archivebox.crawls.migrations.0018_freeze_crawl_config_snapshots")
+ user = _user("frozen-config-migration-admin")
+ persona = _persona(user, name="Migration Persona", user_agent="Migration UA")
+ crawl = Crawl(
+ urls="https://example.com/migration",
+ persona=persona,
+ created_by=user,
+ config={"TIMEOUT": 44, "CHROME_BINARY": "migration-chrome"},
+ status=Crawl.StatusChoices.QUEUED,
+ retry_at=timezone.now(),
+ )
+ crawl.save()
+
+ Crawl.objects.filter(id=crawl.id).update(config={"TIMEOUT": 44, "CHROME_BINARY": "migration-chrome"})
+ migration.freeze_existing_crawl_configs(apps, SimpleNamespace(connection=connection))
+
+ crawl.refresh_from_db()
+ assert crawl.config["TIMEOUT"] == 44
+ assert "CHROME_BINARY" not in crawl.config
+ assert crawl.config["USER_AGENT"] == "Migration UA"
+ assert crawl.config["TWOCAPTCHA_API_KEY"] == SENSITIVE_SECRET
+ assert "ACTIVE_PERSONA" not in crawl.config
+ assert "DEFAULT_PERSONA" not in crawl.config
+ assert "CRAWL_DIR" not in crawl.config
+ assert "SNAP_DIR" not in crawl.config
+ assert "SECRET_KEY" not in crawl.config
diff --git a/archivebox/tests/test_hooks.py b/archivebox/tests/test_hooks.py
index b544240b..28e76aa2 100755
--- a/archivebox/tests/test_hooks.py
+++ b/archivebox/tests/test_hooks.py
@@ -45,10 +45,11 @@ def create_test_plugin_structure(plugins_dir: Path) -> None:
def run_plugin_discovery_subprocess(tmp_path: Path, plugins_dir: Path, script: str):
env = os.environ.copy()
- env["ARCHIVEBOX_USER_PLUGINS_DIR"] = str(plugins_dir)
data_dir = tmp_path / "data"
data_dir.mkdir()
- env["DATA_DIR"] = str(data_dir)
+ cwd_plugins_dir = data_dir / "custom_plugins"
+ if plugins_dir != cwd_plugins_dir:
+ shutil.copytree(plugins_dir, cwd_plugins_dir)
env["PYTHONPATH"] = str(REPO_ROOT) + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
subprocess_script = "\n".join(
[
@@ -205,7 +206,7 @@ class TestRequiredBinaryConfigHandling:
assert binary_name == "wget2"
def test_binary_env_var_empty_default(self):
- """Empty configured values should fall back to config defaults."""
+ """Empty configured binary values should keep the schema default."""
configured_binary = ""
if configured_binary:
binary_name = configured_binary
@@ -736,7 +737,6 @@ print(json.dumps({
hook_path,
output_dir,
config={
- "DATA_DIR": str(tmp_path),
"LIB_DIR": str(lib_dir),
"NODE_PATH": configured_node_path,
},
diff --git a/archivebox/tests/test_machine_models.py b/archivebox/tests/test_machine_models.py
index 8f47ec0c..49d5692a 100644
--- a/archivebox/tests/test_machine_models.py
+++ b/archivebox/tests/test_machine_models.py
@@ -90,7 +90,6 @@ class TestMachineModel:
def test_machine_config_save_heals_json_encoded_string_values(self, machine):
machine.config = {
- "PERSONAS_DIR": '"/data/personas"',
"EXTRA_CONTEXT": 'prefix "inner" suffix',
"USER_AGENT": '"ArchiveBox \\"Quoted\\" Agent"',
}
@@ -98,7 +97,6 @@ class TestMachineModel:
machine.refresh_from_db()
- assert machine.config["PERSONAS_DIR"] == "/data/personas"
assert machine.config["EXTRA_CONTEXT"] == 'prefix "inner" suffix'
assert machine.config["USER_AGENT"] == 'ArchiveBox "Quoted" Agent'
diff --git a/archivebox/tests/test_misc_checks.py b/archivebox/tests/test_misc_checks.py
index 53e09c29..ecf7e47c 100644
--- a/archivebox/tests/test_misc_checks.py
+++ b/archivebox/tests/test_misc_checks.py
@@ -1,6 +1,10 @@
import os
import signal
+import pytest
+
+from archivebox.core.shutdown_util import foreground_shutdown_signals
+from archivebox.core.shutdown_util import raise_if_shutdown_requested
from archivebox.misc.checks import _migration_interrupt_message
from archivebox.misc.checks import _exit_on_migration_interrupt
@@ -38,3 +42,16 @@ def test_migration_interrupt_handler_exits_for_sigint_and_sigterm(monkeypatch):
else:
raise AssertionError(f"{sig.name} should exit during migration auto-apply")
assert signal.getsignal(sig) == previous_handler
+
+
+def test_nested_foreground_signal_state_propagates_to_outer_context():
+ with foreground_shutdown_signals(first_signal_message=None) as outer_state:
+ try:
+ with foreground_shutdown_signals(first_signal_message=None):
+ os.kill(os.getpid(), signal.SIGTERM)
+ except KeyboardInterrupt:
+ pass
+
+ assert outer_state.signal_name == "SIGTERM"
+ with pytest.raises(KeyboardInterrupt):
+ raise_if_shutdown_requested()
diff --git a/archivebox/tests/test_orm_helpers.py b/archivebox/tests/test_orm_helpers.py
index a322e6a2..c6438fd3 100644
--- a/archivebox/tests/test_orm_helpers.py
+++ b/archivebox/tests/test_orm_helpers.py
@@ -17,23 +17,20 @@ def use_archivebox_db(path: str | Path = ".") -> Iterator[None]:
original_name = connection.settings_dict["NAME"]
original_database_name = connections.databases["default"]["NAME"]
original_setting_name = settings.DATABASES["default"]["NAME"]
- original_connection = getattr(connections._connections, "default", None)
+ original_connection = connections._connections.default
db_path = str(archivebox_db_path(path))
connection.close()
connection.settings_dict["NAME"] = db_path
connections.databases["default"]["NAME"] = db_path
settings.DATABASES["default"]["NAME"] = db_path
- if original_connection is not None:
- delattr(connections._connections, "default")
+ del connections._connections.default
try:
yield
finally:
connections["default"].close()
connections.databases["default"]["NAME"] = original_database_name
settings.DATABASES["default"]["NAME"] = original_setting_name
- if hasattr(connections._connections, "default"):
- delattr(connections._connections, "default")
- if original_connection is not None:
- original_connection.settings_dict["NAME"] = original_name
- setattr(connections._connections, "default", original_connection)
+ del connections._connections.default
+ original_connection.settings_dict["NAME"] = original_name
+ connections._connections.default = original_connection
diff --git a/archivebox/tests/test_persona_admin.py b/archivebox/tests/test_persona_admin.py
index 4cda9326..7176da3b 100644
--- a/archivebox/tests/test_persona_admin.py
+++ b/archivebox/tests/test_persona_admin.py
@@ -5,6 +5,7 @@ from django.contrib.auth import get_user_model
from django.contrib.auth.models import UserManager
from django.urls import reverse
+from archivebox.config.common import ArchiveBoxConfig
from archivebox.personas.importers import (
discover_persona_template_profiles,
import_persona_from_source,
@@ -175,20 +176,20 @@ def test_persona_admin_add_post_runs_shared_importer(client, admin_user):
def test_persona_admin_saves_typed_plugin_config(client, admin_user):
- from archivebox.config.common import get_config
-
client.login(username="personaadmin", password="testpassword")
add_response = client.get(reverse("admin:personas_persona_add"), HTTP_HOST=ADMIN_HOST)
add_form = add_response.context["adminform"].form
- personas_dir_fields = [
- field
- for group in add_form.plugin_groups
- for card in group["plugins"]
- for field in card["config_fields"]
- if field["key"] == "PERSONAS_DIR"
- ]
- assert personas_dir_fields
- assert {field["value"] for field in personas_dir_fields} == {str(get_config().PERSONAS_DIR)}
+ exposed_config_keys = {field["key"] for group in add_form.plugin_groups for card in group["plugins"] for field in card["config_fields"]}
+ assert not {key for key in exposed_config_keys if ArchiveBoxConfig.scope_for_key(key) == "crawl_execution"}
+ assert (
+ not {
+ "ARCHIVE_DIR",
+ "USERS_DIR",
+ "PERSONAS_DIR",
+ "CUSTOM_TEMPLATES_DIR",
+ }
+ & exposed_config_keys
+ )
response = client.post(
reverse("admin:personas_persona_add"),
@@ -216,7 +217,6 @@ def test_persona_config_save_heals_json_encoded_string_values(admin_user):
name="QuotedConfigPersona",
created_by=admin_user,
config={
- "PERSONAS_DIR": '"/data/personas"',
"EXTRA_CONTEXT": 'prefix "inner" suffix',
"USER_AGENT": '"ArchiveBox \\"Quoted\\" Agent"',
},
@@ -224,6 +224,5 @@ def test_persona_config_save_heals_json_encoded_string_values(admin_user):
persona.refresh_from_db()
- assert persona.config["PERSONAS_DIR"] == "/data/personas"
assert persona.config["EXTRA_CONTEXT"] == 'prefix "inner" suffix'
assert persona.config["USER_AGENT"] == 'ArchiveBox "Quoted" Agent'
diff --git a/archivebox/tests/test_persona_runtime.py b/archivebox/tests/test_persona_runtime.py
index f087162a..bf02289d 100644
--- a/archivebox/tests/test_persona_runtime.py
+++ b/archivebox/tests/test_persona_runtime.py
@@ -218,7 +218,7 @@ def test_get_config_treats_missing_persona_id_as_null(initialized_archive):
assert payload["persona_id"] == "None"
-def test_get_config_resolves_parent_scopes_when_only_archiveresult_is_passed(initialized_archive):
+def test_get_config_resolves_parent_scopes_for_snapshot_runtime(initialized_archive):
script = textwrap.dedent(
"""
import json
@@ -237,6 +237,7 @@ def test_get_config_resolves_parent_scopes_when_only_archiveresult_is_passed(ini
from archivebox.crawls.models import Crawl
from archivebox.machine.models import Machine
from archivebox.personas.models import Persona
+ from archivebox.services.runner import CrawlRunner
CONSTANTS.CONFIG_FILE.write_text('[ARCHIVING_CONFIG]\\nTIMEOUT=11\\nCHROME_BINARY=file-chrome\\n')
@@ -254,6 +255,8 @@ def test_get_config_resolves_parent_scopes_when_only_archiveresult_is_passed(ini
persona_id=persona.id,
config={'TIMEOUT': 44, 'CHROME_BINARY': 'crawl-chrome'},
)
+ persona.config = {'TIMEOUT': 99, 'CHROME_BINARY': 'persona-chrome-updated'}
+ persona.save(update_fields=['config'])
snapshot = Snapshot.objects.create(
url='https://example.com',
crawl=crawl,
@@ -262,7 +265,6 @@ def test_get_config_resolves_parent_scopes_when_only_archiveresult_is_passed(ini
result = ArchiveResult.objects.create(
snapshot=snapshot,
plugin='title',
- config={'TIMEOUT': 66, 'CHROME_BINARY': 'archiveresult-chrome'},
)
env_config = get_config(include_machine=False)
@@ -270,8 +272,10 @@ def test_get_config_resolves_parent_scopes_when_only_archiveresult_is_passed(ini
persona_config = get_config(persona=persona)
crawl_config = get_config(crawl=crawl)
snapshot_config = get_config(snapshot=snapshot)
- result_config = get_config(archiveresult=result)
- override_config = get_config(archiveresult=result, overrides={'TIMEOUT': 77, 'CHROME_BINARY': 'override-chrome'})
+ override_config = get_config(snapshot=snapshot, overrides={'TIMEOUT': 77, 'CHROME_BINARY': 'override-chrome'})
+ runner = CrawlRunner(crawl, selected_plugins=['title'], show_progress=False)
+ runner.load_run_state()
+ runtime_config = runner.load_snapshot_payload(str(snapshot.id))['config']
print(json.dumps({
'env': [env_config.TIMEOUT, env_config.CHROME_BINARY],
@@ -279,13 +283,12 @@ def test_get_config_resolves_parent_scopes_when_only_archiveresult_is_passed(ini
'persona': [persona_config.TIMEOUT, persona_config.CHROME_BINARY],
'crawl': [crawl_config.TIMEOUT, crawl_config.CHROME_BINARY],
'snapshot': [snapshot_config.TIMEOUT, snapshot_config.CHROME_BINARY],
- 'archiveresult': [result_config.TIMEOUT, result_config.CHROME_BINARY],
'override': [override_config.TIMEOUT, override_config.CHROME_BINARY],
- 'snap_dir': str(result_config.SNAP_DIR),
+ 'snap_dir': str(runtime_config['SNAP_DIR']),
'expected_snap_dir': str(snapshot.output_dir),
- 'crawl_dir': str(result_config.CRAWL_DIR),
+ 'crawl_dir': str(runtime_config['CRAWL_DIR']),
'expected_crawl_dir': str(crawl.output_dir),
- 'active_persona': result_config.ACTIVE_PERSONA,
+ 'active_persona': runtime_config['ACTIVE_PERSONA'],
}, default=str))
""",
)
@@ -296,11 +299,10 @@ def test_get_config_resolves_parent_scopes_when_only_archiveresult_is_passed(ini
payload = json.loads(stdout.strip().splitlines()[-1])
assert payload["env"] == [22, "env-chrome"]
assert payload["machine"] == [22, "machine-chrome"]
- assert payload["persona"] == [33, "persona-chrome"]
- assert payload["crawl"] == [44, "crawl-chrome"]
- assert payload["snapshot"] == [55, "snapshot-chrome"]
- assert payload["archiveresult"] == [66, "archiveresult-chrome"]
- assert payload["override"] == [77, "override-chrome"]
+ assert payload["persona"] == [99, "persona-chrome-updated"]
+ assert payload["crawl"] == [44, "persona-chrome-updated"]
+ assert payload["snapshot"] == [55, "persona-chrome-updated"]
+ assert payload["override"] == [77, "persona-chrome-updated"]
assert payload["snap_dir"] == payload["expected_snap_dir"]
assert payload["crawl_dir"] == payload["expected_crawl_dir"]
assert payload["active_persona"] == "StackPersona"
diff --git a/archivebox/tests/test_search.py b/archivebox/tests/test_search.py
index 79911298..ff868404 100644
--- a/archivebox/tests/test_search.py
+++ b/archivebox/tests/test_search.py
@@ -4,7 +4,6 @@ import socket
import subprocess
import sys
import time
-from pathlib import Path
from types import SimpleNamespace
from urllib.parse import urlencode
@@ -97,8 +96,6 @@ def test_search_backend_env_exposes_resolved_runtime_config(tmp_path):
os.environ["SEARCH_BACKEND_SONIC_HOST_NAME"] = "old-host"
config = AttrDict(
{
- "DATA_DIR": tmp_path,
- "USERS_DIR": tmp_path / "archive" / "users",
"SEARCH_BACKEND_ENGINE": "sonic",
"SEARCH_BACKEND_SONIC_HOST_NAME": "sonic",
"SEARCH_BACKEND_SONIC_PORT": 1491,
@@ -109,8 +106,6 @@ def test_search_backend_env_exposes_resolved_runtime_config(tmp_path):
try:
with search_backend_env(config=config):
- assert os.environ["DATA_DIR"] == str(tmp_path)
- assert os.environ["SNAP_DIR"] == str(Path(tmp_path) / "archive" / "users")
assert os.environ["SEARCH_BACKEND_ENGINE"] == "sonic"
assert os.environ["SEARCH_BACKEND_SONIC_HOST_NAME"] == "sonic"
assert os.environ["SEARCH_BACKEND_SONIC_PORT"] == "1491"
@@ -465,7 +460,6 @@ class TestSearchBackendsE2E:
merged_env = os.environ.copy()
merged_env.update(
{
- "DATA_DIR": str(data_dir),
"USE_COLOR": "False",
"SHOW_PROGRESS": "False",
"SAVE_WARC": "False",
@@ -505,7 +499,6 @@ class TestSearchBackendsE2E:
sonic_env = os.environ.copy()
sonic_env.update(
{
- "DATA_DIR": str(data_dir),
"USE_COLOR": "False",
"SHOW_PROGRESS": "False",
"SEARCH_BACKEND_ENGINE": "sonic",
diff --git a/archivebox/tests/test_shutdown_util.py b/archivebox/tests/test_shutdown_util.py
new file mode 100644
index 00000000..f881f750
--- /dev/null
+++ b/archivebox/tests/test_shutdown_util.py
@@ -0,0 +1,76 @@
+import signal
+
+import pytest
+
+from archivebox.cli import archivebox_run
+from archivebox.core import shutdown_util
+
+
+def test_foreground_shutdown_second_signal_exits_immediately(monkeypatch):
+ def fake_exit(code):
+ raise SystemExit(code)
+
+ monkeypatch.setattr(shutdown_util.os, "_exit", fake_exit)
+
+ with shutdown_util.foreground_shutdown_signals() as state:
+ handler = signal.getsignal(signal.SIGTERM)
+
+ with pytest.raises(KeyboardInterrupt):
+ handler(signal.SIGTERM, None)
+ assert state.signal_name == "SIGTERM"
+
+ with pytest.raises(SystemExit) as err:
+ handler(signal.SIGTERM, None)
+ assert err.value.code == 130
+
+
+def test_foreground_shutdown_can_request_cooperative_shutdown_without_raising(monkeypatch):
+ def fake_exit(code):
+ raise SystemExit(code)
+
+ seen = []
+ monkeypatch.setattr(shutdown_util.os, "_exit", fake_exit)
+
+ with shutdown_util.foreground_shutdown_signals(
+ first_signal_message=None,
+ on_signal=seen.append,
+ raise_on_first_signal=False,
+ ) as state:
+ handler = signal.getsignal(signal.SIGTERM)
+
+ handler(signal.SIGTERM, None)
+ assert state.signal_name == "SIGTERM"
+ assert seen == [signal.SIGTERM]
+
+ with pytest.raises(SystemExit) as err:
+ handler(signal.SIGTERM, None)
+ assert err.value.code == 130
+
+
+def test_daemon_runner_signal_exit_is_unexpected_for_supervisor(monkeypatch):
+ def fake_exit(code):
+ raise SystemExit(code)
+
+ monkeypatch.setattr(archivebox_run.os, "_exit", fake_exit)
+
+ with pytest.raises(SystemExit) as err:
+ archivebox_run._exit_daemon_runner_on_signal(signal.SIGTERM)
+
+ assert err.value.code == 143
+
+
+def test_crawl_runner_daemon_signal_exits_before_async_cleanup(monkeypatch):
+ from archivebox.services import runner as runner_module
+ from archivebox.services.runner import CrawlRunner
+
+ def fake_exit(code):
+ raise SystemExit(code)
+
+ runner = object.__new__(CrawlRunner)
+ monkeypatch.setenv("ARCHIVEBOX_RUNNER_DAEMON", "1")
+ monkeypatch.setattr(runner_module.os, "_exit", fake_exit)
+
+ with pytest.raises(SystemExit) as err:
+ runner._request_abort_from_signal(signal.SIGTERM)
+
+ assert err.value.code == 143
diff --git a/archivebox/tests/test_test_harness.py b/archivebox/tests/test_test_harness.py
index 23c3b249..fc7d3f23 100644
--- a/archivebox/tests/test_test_harness.py
+++ b/archivebox/tests/test_test_harness.py
@@ -18,15 +18,17 @@ def test_in_process_archivebox_config_uses_temp_data_dir():
from archivebox.config import CONSTANTS
from archivebox.config.common import get_config
- data_dir = Path(os.environ["DATA_DIR"]).resolve()
+ data_dir = CONSTANTS.DATA_DIR.resolve()
assert data_dir == Path.cwd().resolve()
assert test_harness.REPO_ROOT not in data_dir.parents
assert CONSTANTS.DATA_DIR != test_harness.REPO_ROOT
config = get_config(include_machine=False)
- assert config.DATA_DIR == data_dir
- assert config.ARCHIVE_DIR == data_dir / "archive"
- assert config.USERS_DIR == data_dir / "archive" / "users"
+ assert "DATA_DIR" not in config
+ assert "ARCHIVE_DIR" not in config
+ assert "USERS_DIR" not in config
+ assert CONSTANTS.ARCHIVE_DIR == data_dir / "archive"
+ assert CONSTANTS.USERS_DIR == data_dir / "archive" / "users"
def test_cli_helpers_reject_repo_root_runtime_paths():
diff --git a/archivebox/tests/test_ui_add_view.py b/archivebox/tests/test_ui_add_view.py
index b82e896c..692d7822 100644
--- a/archivebox/tests/test_ui_add_view.py
+++ b/archivebox/tests/test_ui_add_view.py
@@ -3,10 +3,11 @@ import pytest
from django.contrib.auth import get_user_model
from django.urls import reverse
-from archivebox.config.common import get_config
+from archivebox.config.common import ArchiveBoxConfig
from archivebox.core.models import Snapshot, Tag
from archivebox.crawls.models import Crawl
from archivebox.personas.models import Persona
+from archivebox.services.runner import CrawlRunner
from archivebox.workers.models import RETRY_AT_MAX
@@ -74,15 +75,28 @@ def test_add_view_admin_renders_plugin_config_grid(client, admin_user, monkeypat
assert b">Docs" in response.content
assert b"https://github.com/ArchiveBox/abx-plugins/tree/main/abx_plugins/plugins/" in response.content
assert b"https://archivebox.github.io/abx-plugins/#" in response.content
- personas_dir_fields = [
- field
+ assert not any(
+ field["key"].endswith("_BINARY") for group in form.plugin_groups for card in group["plugins"] for field in card["config_fields"]
+ )
+ assert not any(
+ field["key"] == f"{card['name'].upper()}_ENABLED"
for group in form.plugin_groups
for card in group["plugins"]
for field in card["config_fields"]
- if field["key"] == "PERSONAS_DIR"
- ]
- assert personas_dir_fields
- assert {field["value"] for field in personas_dir_fields} == {str(get_config().PERSONAS_DIR)}
+ )
+ exposed_config_keys = {field["key"] for group in form.plugin_groups for card in group["plugins"] for field in card["config_fields"]}
+ assert not {key for key in exposed_config_keys if ArchiveBoxConfig.scope_for_key(key) == "crawl_execution"}
+ assert (
+ not {
+ "ARCHIVE_DIR",
+ "USERS_DIR",
+ "PERSONAS_DIR",
+ "CUSTOM_TEMPLATES_DIR",
+ }
+ & exposed_config_keys
+ )
+ assert b"plugin_config__chrome__CHROME_BINARY" not in response.content
+ assert b"plugin_config__wget__WGET_ENABLED" not in response.content
def test_add_view_embeds_selected_persona_config_for_ui_hydration(client, admin_user, monkeypatch):
@@ -127,6 +141,20 @@ def test_add_view_public_only_lists_public_personas(client, admin_user, monkeypa
assert {"NODE_BINARY", "TWOCAPTCHA_API_KEY"}.isdisjoint(persona_config_map["Default"]["effective_config"])
+def test_persona_config_grid_allows_binary_fields(client, admin_user):
+ client.force_login(admin_user)
+ persona = Persona.objects.create(name="Binary Persona", created_by=admin_user)
+
+ response = client.get(
+ reverse("admin:personas_persona_change", args=[persona.pk]),
+ HTTP_HOST=ADMIN_HOST,
+ )
+
+ assert response.status_code == 200
+ assert b"plugin_config__chrome__CHROME_BINARY" in response.content
+ assert b"plugin_config__wget__WGET_ENABLED" in response.content
+
+
def test_add_view_hides_search_backend_plugins(client, monkeypatch):
monkeypatch.setenv("PUBLIC_ADD_VIEW", "true")
monkeypatch.setenv("SEARCH_BACKEND_ENGINE", "sqlite")
@@ -253,11 +281,14 @@ 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 crawl.config["ACTIVE_PERSONA"] == "Private"
+ assert "ACTIVE_PERSONA" not in crawl.config
assert crawl.resolve_persona() == private_persona
- runtime_config = get_config(crawl=crawl)
- assert runtime_config.ACTIVE_PERSONA == "Private"
- assert runtime_config.COOKIES_FILE == private_cookies_file
+ snapshot = Snapshot.objects.create(url="https://example.com/private", crawl=crawl)
+ runner = CrawlRunner(crawl, selected_plugins=["title"], show_progress=False)
+ runner.load_run_state()
+ runtime_config = runner.load_snapshot_payload(str(snapshot.id))["config"]
+ assert runtime_config["ACTIVE_PERSONA"] == "Private"
+ assert runtime_config["COOKIES_FILE"] == str(private_cookies_file)
def test_add_view_applies_plugin_config_overrides(client, admin_user, monkeypatch):
@@ -282,9 +313,11 @@ def test_add_view_applies_plugin_config_overrides(client, admin_user, monkeypatc
"permissions": "public",
"start_paused": "",
"main_plugins": ["wget"],
+ "plugin_config__wget__WGET_ENABLED": "false",
"plugin_config__wget__WGET_TIMEOUT": "77",
"plugin_config__wget__WGET_WARC_ENABLED": "false",
- "config": "{}",
+ "plugin_config__chrome__CHROME_BINARY": "/tmp/malicious-chrome",
+ "config": '{"NODE_BINARY": "/tmp/malicious-node", "WGET_ENABLED": false}',
},
HTTP_HOST=ADMIN_HOST,
)
@@ -296,6 +329,9 @@ def test_add_view_applies_plugin_config_overrides(client, admin_user, monkeypatc
assert crawl.config["PLUGINS"] == "wget"
assert crawl.config["WGET_TIMEOUT"] == 77
assert crawl.config["WGET_WARC_ENABLED"] is False
+ assert "WGET_ENABLED" not in crawl.config
+ assert "CHROME_BINARY" not in crawl.config
+ assert "NODE_BINARY" not in crawl.config
def test_add_view_public_submission_ignores_plugin_and_custom_config(client, admin_user, monkeypatch):
diff --git a/archivebox/tests/test_urls.py b/archivebox/tests/test_urls.py
index 8cfb2f45..f3dd3f52 100644
--- a/archivebox/tests/test_urls.py
+++ b/archivebox/tests/test_urls.py
@@ -73,7 +73,7 @@ def _build_script(body: str) -> str:
from archivebox.core.middleware import ADMIN_LOGIN_HINT_COOKIE
def response_body(resp):
- if getattr(resp, "streaming", False):
+ if resp.streaming:
return b"".join(resp.streaming_content)
return resp.content
@@ -761,13 +761,16 @@ class TestUrlRouting:
},
)
- def test_subdomain_replay_assets_fall_back_to_chromewebstore_lib_dir(self) -> None:
+ def test_subdomain_replay_assets_use_derived_chromewebstore_extensions_dir(self) -> None:
lib_dir = self.data_dir / "test-lib"
self._run(
"""
snapshot = get_snapshot()
snapshot_host = get_snapshot_host(str(snapshot.id))
- extension_dir = Path(SERVER_CONFIG.LIB_DIR) / "chromewebstore" / "extensions" / "test__archivewebpage"
+ expected_extensions_dir = Path(SERVER_CONFIG.LIB_DIR) / "chromewebstore" / "extensions"
+ assert Path(SERVER_CONFIG.CHROME_EXTENSIONS_DIR).resolve() == expected_extensions_dir.resolve()
+
+ extension_dir = Path(SERVER_CONFIG.CHROME_EXTENSIONS_DIR) / "test__archivewebpage"
extension_dir.mkdir(parents=True, exist_ok=True)
(extension_dir / "ui.js").write_text("window.__archivebox_replay_ui_from_lib__ = true;\\n", encoding="utf-8")
(extension_dir / "sw.js").write_text("self.__archivebox_replay_sw_from_lib__ = true;\\n", encoding="utf-8")
@@ -776,7 +779,6 @@ class TestUrlRouting:
resp = client.get("/replay/ui.js", HTTP_HOST=snapshot_host)
body = response_body(resp).decode("utf-8", "ignore")
- assert SERVER_CONFIG.CHROME_EXTENSIONS_DIR == ""
assert resp.status_code == 200
assert resp["Content-Type"].startswith("application/javascript")
assert "window.__archivebox_replay_ui_from_lib__" in body
@@ -788,7 +790,6 @@ class TestUrlRouting:
"BIND_ADDR": "127.0.0.1:8766",
"BASE_URL": "",
"LIB_DIR": str(lib_dir),
- "CHROME_EXTENSIONS_DIR": "",
},
)
diff --git a/archivebox/workers/supervisord_util.py b/archivebox/workers/supervisord_util.py
index e4cdeb42..7769cec2 100644
--- a/archivebox/workers/supervisord_util.py
+++ b/archivebox/workers/supervisord_util.py
@@ -159,7 +159,7 @@ RUNNER_WORKER = {
"command": _shell_join([sys.executable, "-m", "archivebox", "run", "--daemon"]),
"autostart": "false",
"autorestart": "unexpected",
- "environment": 'PYTHONUNBUFFERED="1",COLUMNS="200"',
+ "environment": 'PYTHONUNBUFFERED="1",COLUMNS="200",ARCHIVEBOX_RUNNER_DAEMON="1"',
"stopasgroup": "true",
"killasgroup": "true",
"stopwaitsecs": "30",
@@ -171,6 +171,7 @@ RUNNER_ONCE_WORKER = lambda args, name="worker_runner_once": {
**RUNNER_WORKER,
"name": name,
"command": _shell_join([sys.executable, "-m", "archivebox", "run", *args]),
+ "environment": 'PYTHONUNBUFFERED="1",COLUMNS="200"',
"autorestart": "false",
"stopwaitsecs": "1",
"stdout_logfile": f"logs/{name}.log",
@@ -671,7 +672,7 @@ def stop_existing_supervisord_process():
pass
-def stop_own_supervisord_process():
+def stop_own_supervisord_process(*, record_exit: bool = True):
"""Stop only the supervisord child started by this Python process."""
global _supervisord_proc
@@ -702,19 +703,20 @@ def stop_own_supervisord_process():
except psutil.NoSuchProcess:
pass
wait_popen_and_kill_children(_supervisord_proc, children, timeout=2.0, kill_timeout=1.0)
- try:
- from archivebox.machine.models import Machine, Process
+ if record_exit:
+ try:
+ from archivebox.machine.models import Machine, Process
- for process in Process.objects.filter(
- machine=Machine.current(),
- process_type=Process.TypeChoices.SUPERVISORD,
- status=Process.StatusChoices.RUNNING,
- pwd=str(CONSTANTS.DATA_DIR),
- pid=stopped_pid,
- ).iterator(chunk_size=10):
- process.mark_exited(exit_code=0)
- except Exception:
- pass
+ for process in Process.objects.filter(
+ machine=Machine.current(),
+ process_type=Process.TypeChoices.SUPERVISORD,
+ status=Process.StatusChoices.RUNNING,
+ pwd=str(CONSTANTS.DATA_DIR),
+ pid=stopped_pid,
+ ).iterator(chunk_size=10):
+ process.mark_exited(exit_code=0)
+ except Exception:
+ pass
except (BrokenPipeError, OSError, psutil.TimeoutExpired):
pass
finally:
@@ -970,10 +972,10 @@ def build_server_worker_plan(*, config, host: str, port: str, debug: bool, reloa
except Exception:
current_sonic = None
supervisor_pid = None
- sonic_host = str(getattr(config, "SEARCH_BACKEND_SONIC_HOST_NAME", "127.0.0.1") or "127.0.0.1")
+ sonic_host = str(config.SEARCH_BACKEND_SONIC_HOST_NAME or "127.0.0.1")
if sonic_host.strip().lower() == "localhost":
sonic_host = "127.0.0.1"
- sonic_port = int(getattr(config, "SEARCH_BACKEND_SONIC_PORT"))
+ sonic_port = int(config.SEARCH_BACKEND_SONIC_PORT)
if not (isinstance(current_sonic, dict) and current_sonic.get("statename") in ("STARTING", "RUNNING")):
stop_stale_sonic_processes(sonic_worker, supervisor_pid=supervisor_pid, host=sonic_host, port=sonic_port)
if not (isinstance(current_sonic, dict) and current_sonic.get("statename") in ("STARTING", "RUNNING")) and is_port_in_use(
@@ -1304,11 +1306,15 @@ def start_server_workers(
raise
STDERR.print(f"\n[🛑] Got {e.__class__.__name__} exception, stopping gracefully...")
finally:
- if not daemonize and (should_stop_supervisord is None or should_stop_supervisord()):
+ signal_shutdown_requested = bool(shutdown_state and shutdown_state.signal_name)
+ if not daemonize and (signal_shutdown_requested or should_stop_supervisord is None or should_stop_supervisord()):
# Ensure supervisord and all children are stopped only while this
# foreground parent is still the active server parent. Standby
- # parents must not tear down a newer leader's services.
- stop_own_supervisord_process()
+ # parents must not tear down a newer leader's services. If this
+ # foreground parent itself received an OS shutdown signal, always
+ # stop the supervisord child it owns; stop_own_supervisord_process()
+ # does not target supervisord processes owned by other parents.
+ stop_own_supervisord_process(record_exit=not signal_shutdown_requested)
return tail_result
diff --git a/docs/Configuration.md b/docs/Configuration.md
index 0714ccff..73d494a6 100644
--- a/docs/Configuration.md
+++ b/docs/Configuration.md
@@ -1,6 +1,6 @@
# Configuration
-Configuration of ArchiveBox is done by using the `archivebox config` command, modifying the `ArchiveBox.conf` file in the data folder, or by using environment variables. All three methods work equivalently when using Docker as well.
+Configuration of ArchiveBox is done by using the `archivebox config` command, modifying the `ArchiveBox.conf` file in the data folder, or by setting environment variables as process defaults. All three methods work in Docker as well.
*Some equivalent examples of setting some configuration options:*
```bash
@@ -11,7 +11,7 @@ echo "TIMEOUT=120" >> ArchiveBox.conf
env TIMEOUT=120 archivebox add ~/Downloads/bookmarks_export.html
```
-Environment variables take precedence over the config file, which is useful if you only want to use a certain option temporarily during a single run. For more examples see [Usage: Configuration](Usage#run-archivebox-with-configuration-options)...
+Environment variables seed process-level defaults. Persisted Machine, Persona, Crawl, and Snapshot settings can override them depending on scope, and existing Crawl config is not silently overwritten by later environment changes. Runtime-derived values like crawl/snapshot output dirs are resolved fresh for each run instead of being stored in frozen crawl config. For more examples see [Usage: Configuration](Usage#run-archivebox-with-configuration-options)...