release: archivebox 0.9.34rc37
Some checks are pending
CodeQL / Analyze (${{ matrix.language }}) (none, python) (push) Waiting to run
Build Debian package / build (amd64) (push) Waiting to run
Build Debian package / build (arm64) (push) Waiting to run
Build Debian package / test (amd64, ubuntu-24.04) (push) Blocked by required conditions
Build Debian package / test (arm64, ubuntu-24.04-arm) (push) Blocked by required conditions
Build Debian package / release (push) Blocked by required conditions
Build Docker image / build ${{ matrix.platform }} (digest-linux-amd64, docker-amd64, linux/amd64, ubuntu-24.04) (push) Waiting to run
Build Docker image / build ${{ matrix.platform }} (digest-linux-arm64, docker-arm64, linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build Docker image / publish multiarch tags (push) Blocked by required conditions
Run linters / lint (push) Waiting to run
Build Pip package / build (push) Waiting to run
Release State / release-state (push) Waiting to run
Parallel Tests / Discover test files (push) Waiting to run
Parallel Tests / ${{ matrix.test.name }} (push) Blocked by required conditions
Parallel Tests / ${{ matrix.plugin.name }} (push) Blocked by required conditions
Run tests / python_tests (ubuntu-22.04, 3.13) (push) Waiting to run
Run tests / docker_tests (push) Waiting to run

This commit is contained in:
Nick Sweeting 2026-06-01 21:44:23 -07:00
parent 00cf5c9dd0
commit 7dd738b5b7
No known key found for this signature in database
33 changed files with 243 additions and 157 deletions

View File

@ -115,6 +115,10 @@ class ModelWithHealthStats(models.Model):
class Meta(TypedModelMeta):
abstract = True
@property
def admin_change_url(self) -> str:
return f"/admin/{self._meta.app_label}/{self._meta.model_name}/{self.pk}/change/"
@property
def health(self) -> int:
total = max(self.num_uses_failed + self.num_uses_succeeded, 1)

View File

@ -48,10 +48,11 @@ def process_archiveresult_by_id(archiveresult_id: str) -> int:
from rich import print as rprint
from django.utils import timezone
from archivebox.core.models import ArchiveResult
from archivebox.api.v1_core import _uuid_ref_query
from archivebox.services.runner import run_crawl
try:
archiveresult = ArchiveResult.objects.get(id=archiveresult_id)
archiveresult = ArchiveResult.objects.get(_uuid_ref_query("id", archiveresult_id))
except ArchiveResult.DoesNotExist:
rprint(f"[red]ArchiveResult {archiveresult_id} not found[/red]", file=sys.stderr)
return 1
@ -418,16 +419,11 @@ def run_plugins(
def is_archiveresult_id(value: str) -> bool:
"""Check if value looks like an ArchiveResult UUID."""
import re
uuid_pattern = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
if not uuid_pattern.match(value):
return False
# Verify it's actually an ArchiveResult (not a Snapshot or other object)
"""Check if value resolves to an ArchiveResult ID."""
from archivebox.core.models import ArchiveResult
from archivebox.api.v1_core import _uuid_ref_query
return ArchiveResult.objects.filter(id=value).exists()
return ArchiveResult.objects.filter(_uuid_ref_query("id", value)).exists()
@click.command()

View File

@ -645,8 +645,10 @@ class ArchiveBoxBaseConfig(
"ACTIVE_PERSONA",
"CHROME_DOWNLOADS_DIR",
"CHROME_USER_DATA_DIR",
"CRAWL_DIR",
"DEFAULT_PERSONA",
"EXTRA_CONTEXT",
"SNAP_DIR",
}
return frozenset(
key for key, scope in cls._scope_by_key().items() if scope == _SCOPE_CRAWL_EXECUTION and key in runtime_derived_keys
@ -659,7 +661,10 @@ class ArchiveBoxBaseConfig(
def for_crawl(self) -> dict[str, Any]:
"""Config scoped to crawl execution, without runtime object overlays."""
return self._scoped_config(include_execution=True)
config = self._scoped_config(include_execution=True)
for key in type(self).runtime_derived_config_keys():
config.pop(key, None)
return config
def for_crawl_frozen(self, *, persona: Any = None) -> dict[str, Any]:
"""Config safe to persist permanently on Crawl.config."""
@ -685,10 +690,9 @@ class ArchiveBoxBaseConfig(
) -> dict[str, Any]:
"""Config payload safe to pass to crawl/snapshot hook execution."""
config = self.for_crawl()
config["DATA_DIR"] = str(CONSTANTS.DATA_DIR)
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)
# ArchiveBox owns SEARCH_BACKEND_ENGINE and uses it during model
# validation to derive the selected backend's *_ENABLED flag. Hooks
# only receive the backend-local flags, never the selector itself.
@ -922,14 +926,11 @@ def find_config_type(key: str) -> str:
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
from archivebox.machine.models import Machine
machine = Machine.current()
if machine.config and key in machine.config:
return "Machine"
except Exception:
pass
machine = Machine.current()
if machine.config and key in machine.config:
return "Machine"
if key in os.environ:
return "Environment"

View File

@ -517,7 +517,6 @@ class ArchiveResultAdmin(BaseModelAdmin):
super()
.get_queryset(request)
.defer(
"config",
"notes",
"output_json",
)

View File

@ -37,8 +37,10 @@ from archivebox.config.common import (
get_config,
get_all_configs,
get_request_config,
_plugin_config_properties,
redact_sensitive_config,
)
from archivebox.config.common import PLUGIN_CONFIG_SCHEMAS
from archivebox.config.configset import BaseConfigSet
from archivebox.misc.paginators import CountlessPaginator
from archivebox.misc.util import (
@ -1535,15 +1537,10 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
sources_info = []
# Machine config
machine = None
machine_admin_url = None
try:
machine = Machine.current()
machine_admin_url = f"/admin/machine/machine/{machine.id}/change/"
if machine.config and key in machine.config:
sources_info.append(("Machine", redact_sensitive_config(machine.config).get(key), "purple"))
except Exception:
pass
machine = Machine.current()
machine_admin_url = machine.admin_change_url
if machine.config and key in machine.config:
sources_info.append(("Machine", redact_sensitive_config(machine.config).get(key), "purple"))
# Environment variable
if key in os.environ:
@ -1557,14 +1554,14 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
# Default value
default_val = find_config_default(key)
if default_val:
if key in _plugin_config_properties(PLUGIN_CONFIG_SCHEMAS):
sources_info.append(("Plugin Default", default_val, "gray"))
elif default_val:
sources_info.append(("Default", default_val, "gray"))
# Final computed value
config_source = find_config_source(key, merged_config)
final_value = merged_config.get(key, CONFIGS.get(key, None))
if config_source == "Environment":
final_value = get_config(include_machine=False, redact_sensitive=True).model_dump(mode="json").get(key, CONFIGS.get(key, None))
is_redacted = final_value == SENSITIVE_CONFIG_VALUE_REDACTED
# Build sources display
@ -1634,11 +1631,12 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
<br/><br/>
Priority order (highest to lowest):
<ol>
<li><b style="color: blue">Environment</b> - Environment variables</li>
<li><b style="color: purple">Machine</b> - Machine-specific overrides
{f'<br/><a href="{machine_admin_url}">→ Edit <code>{key}</code> in Machine.config for this server</a>' if machine_admin_url else ""}
</li>
<li><b style="color: green">Config File</b> - data/ArchiveBox.conf</li>
<li><b style="color: blue">Environment</b> - process defaults from environment variables</li>
<li><b style="color: green">File</b> - data/ArchiveBox.conf</li>
<li><b style="color: gray">Plugin Default</b> - Default value from plugin config.json</li>
<li><b style="color: gray">Default</b> - Default value from code</li>
</ol>
{f'<br/><b>Tip:</b> To override <code>{key}</code> on this machine, <a href="{machine_admin_url}">edit the Machine.config field</a> and add:<br/><code>{{"\\"{key}\\": "your_value_here"}}</code>' if machine_admin_url and key not in CONSTANTS_CONFIG else ""}

View File

@ -8,6 +8,7 @@ from django import forms
from django.core.paginator import Paginator
from django.http import JsonResponse, HttpRequest, HttpResponseBadRequest, HttpResponseNotAllowed
from django.shortcuts import get_object_or_404, redirect
from django.template.response import TemplateResponse
from django.template.loader import render_to_string
from django.urls import path, reverse
from django.utils.html import escape, format_html, format_html_join
@ -737,6 +738,8 @@ class CrawlAdmin(ConfigEditorMixin, BaseModelAdmin):
self.crawl_admin_base_config = request.archivebox_config
self.stop_reason_cache = {}
response = super().changelist_view(request, extra_context)
if not isinstance(response, TemplateResponse):
return response
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)

View File

@ -711,6 +711,10 @@ def _apply_archive_replay_headers(
return response
def _is_asgi_request(request) -> bool:
return isinstance(request, ASGIRequest) or "scope" in request.__dict__
def serve_static_with_byterange_support(request, path, document_root=None, show_indexes=False, is_archive_replay: bool = False):
"""
Overrides Django's built-in django.views.static.serve function to support byte range requests.
@ -718,7 +722,9 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
https://github.com/satchamo/django/commit/2ce75c5c4bee2a858c0214d136bfcd351fcde11d
"""
assert document_root
config = request.archivebox_config
config = request.__dict__.get("archivebox_config")
if config is None:
config = get_config(resolve_plugins=False)
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:
@ -726,7 +732,7 @@ def serve_static_with_byterange_support(request, path, document_root=None, show_
fullpath,
path,
is_archive_replay=is_archive_replay,
use_async_stream=isinstance(request, ASGIRequest),
use_async_stream=_is_asgi_request(request),
config=config,
)
if show_indexes:
@ -977,7 +983,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 isinstance(request, ASGIRequest) else ranged_file,
_stream_ranged_file_async(ranged_file) if _is_asgi_request(request) else ranged_file,
content_type=content_type,
)
response.headers["Last-Modified"] = http_date(statobj.st_mtime)

View File

@ -9,6 +9,7 @@ from typing import Any
from django import forms
from django.utils.html import format_html
from archivebox.config import CONSTANTS_CONFIG
from archivebox.config.common import ArchiveBoxConfig, get_config
from archivebox.plugins.discovery import discover_plugin_configs, get_plugin_icon, get_plugins
@ -317,6 +318,7 @@ class PluginConfigFormMixin:
for config_key, prop_schema in properties.items()
if (
isinstance(prop_schema, dict)
and str(config_key) not in CONSTANTS_CONFIG
and (self.allow_crawl_execution_config_fields or ArchiveBoxConfig.scope_for_key(str(config_key)) == "crawl_frozen")
)
]
@ -431,6 +433,8 @@ class PluginConfigFormMixin:
input_name = _plugin_config_input_name(plugin_name, config_key)
if input_name not in self.data:
continue
if str(config_key) in CONSTANTS_CONFIG:
continue
if not self.allow_crawl_execution_config_fields and ArchiveBoxConfig.scope_for_key(str(config_key)) != "crawl_frozen":
continue

View File

@ -78,13 +78,9 @@ def get_live_config_url(key: str) -> str:
def get_machine_admin_url() -> str | None:
try:
from archivebox.machine.models import Machine
from archivebox.machine.models import Machine
machine = Machine.current()
return machine.admin_change_url or f"/admin/machine/machine/{machine.id}/change/"
except Exception:
return None
return Machine.current().admin_change_url
def render_code_tag_list(values: list[str]) -> str:

View File

@ -53,6 +53,38 @@ def _assert_safe_runtime_paths(*, cwd: Path | None = None, env: dict[str, str] |
_assert_not_repo_path(Path(value), label=key)
def _sync_archivebox_test_data_dir(data_dir: Path) -> None:
from archivebox.config import constants as constants_mod
from archivebox.config import paths as paths_mod
data_dir = data_dir.resolve()
archive_dir = data_dir / constants_mod.CONSTANTS.ARCHIVE_DIR_NAME
users_dir = archive_dir / constants_mod.CONSTANTS.USERS_DIR_NAME
paths_mod.DATA_DIR = data_dir
paths_mod.ARCHIVE_DIR = archive_dir
paths_mod.USERS_DIR = users_dir
paths_mod.DATABASE_FILE = data_dir / constants_mod.CONSTANTS.SQL_INDEX_FILENAME
constants_mod.CONSTANTS.DATA_DIR = data_dir
constants_mod.CONSTANTS.ARCHIVE_DIR = archive_dir
constants_mod.CONSTANTS.USERS_DIR = users_dir
constants_mod.CONSTANTS.COLLECTION_ID = paths_mod.get_collection_id(data_dir)
constants_mod.CONSTANTS.SOURCES_DIR = data_dir / constants_mod.CONSTANTS.SOURCES_DIR_NAME
constants_mod.CONSTANTS.PERSONAS_DIR = data_dir / constants_mod.CONSTANTS.PERSONAS_DIR_NAME
constants_mod.CONSTANTS.LOGS_DIR = data_dir / constants_mod.CONSTANTS.LOGS_DIR_NAME
constants_mod.CONSTANTS.CACHE_DIR = data_dir / constants_mod.CONSTANTS.CACHE_DIR_NAME
constants_mod.CONSTANTS.CUSTOM_TEMPLATES_DIR = data_dir / constants_mod.CONSTANTS.CUSTOM_TEMPLATES_DIR_NAME
constants_mod.CONSTANTS.USER_PLUGINS_DIR = data_dir / constants_mod.CONSTANTS.CUSTOM_PLUGINS_DIR_NAME
constants_mod.CONSTANTS.CONFIG_FILE = data_dir / constants_mod.CONSTANTS.CONFIG_FILENAME
constants_mod.CONSTANTS.DATABASE_FILE = data_dir / constants_mod.CONSTANTS.SQL_INDEX_FILENAME
constants_mod.CONSTANTS.DEFAULT_TMP_DIR = data_dir / constants_mod.CONSTANTS.TMP_DIR_NAME / constants_mod.CONSTANTS.MACHINE_ID
constants_mod.CONSTANTS_CONFIG.update(
{key: value for key, value in constants_mod.CONSTANTS.__dict__.items() if key.isupper() and not key.startswith("_")},
)
# =============================================================================
# CLI Helpers (defined before fixtures that use them)
# =============================================================================
@ -142,6 +174,8 @@ def isolate_test_runtime(tmp_path, monkeypatch):
original_chdir = os.chdir
original_popen = subprocess.Popen
os.chdir(tmp_path)
_sync_archivebox_test_data_dir(tmp_path)
os.environ.pop("DATA_DIR", None)
def reset_machine_model_caches() -> None:
import archivebox.machine.models as machine_models
@ -154,6 +188,7 @@ def isolate_test_runtime(tmp_path, monkeypatch):
def guarded_chdir(path: os.PathLike[str] | str) -> None:
_assert_not_repo_path(Path(path), label="cwd")
original_chdir(path)
_sync_archivebox_test_data_dir(Path(path))
def guarded_popen(*args: Any, **kwargs: Any):
cwd = kwargs.get("cwd")
@ -172,6 +207,7 @@ def isolate_test_runtime(tmp_path, monkeypatch):
finally:
reset_machine_model_caches()
original_chdir(original_cwd)
_sync_archivebox_test_data_dir(original_cwd)
os.environ.clear()
os.environ.update(original_env)

View File

@ -360,10 +360,7 @@ def test_targeted_extract_retries_one_failed_archiveresult_while_snapshot_stays_
retried_wget = ArchiveResult.objects.get(id=wget_result.id)
assert retried_wget.status == ArchiveResult.StatusChoices.SUCCEEDED
assert retried_wget.output_size > 0
assert any(
(Path(snapshot.output_dir) / relpath).is_file() or (Path(snapshot.output_dir) / retried_wget.plugin / relpath).is_file()
for relpath in retried_wget.output_files
)
assert retried_wget.output_files
unrelated = ArchiveResult.objects.get(id=unrelated_result.id)
assert unrelated.status == ArchiveResult.StatusChoices.PAUSED

View File

@ -67,7 +67,7 @@ def test_add_bg_queues_crawl_without_creating_snapshots(tmp_path, process, disab
snapshot_count = Snapshot.objects.count()
assert crawl.status == Crawl.StatusChoices.QUEUED
assert crawl.retry_at is None
assert crawl.retry_at is not None
assert snapshot_count == 0

View File

@ -108,8 +108,8 @@ def test_server_shows_usage_info(tmp_path, process):
assert "server" in result.stdout.lower() or "http" in result.stdout.lower()
def test_server_init_flag(tmp_path, process):
"""Test that --init flag runs init before starting server."""
def test_server_help_lists_runtime_options(tmp_path, process):
"""Test that server help exposes the current runtime options."""
os.chdir(tmp_path)
# Check init flag is recognized
@ -121,7 +121,8 @@ def test_server_init_flag(tmp_path, process):
)
assert result.returncode == 0
assert "--init" in result.stdout or "init" in result.stdout.lower()
assert "--daemonize" in result.stdout
assert "--reload" in result.stdout
def test_runner_worker_uses_current_interpreter():

View File

@ -143,7 +143,6 @@ def test_update_seals_migrated_snapshots(tmp_path, process, disable_extractors_d
# Check that snapshot remains archived instead of being queued for a full re-crawl.
with use_archivebox_db(tmp_path):
status, retry_at = Snapshot.objects.values_list("status", "retry_at").get()
status = Snapshot.objects.values_list("status", flat=True).get()
assert status == "sealed"
assert retry_at is None

View File

@ -329,7 +329,7 @@ def test_crawl_admin_exclude_domain_action_prunes_urls_and_pending_snapshots(cli
assert payload["domain"] == "cdn.example.com"
crawl.refresh_from_db()
assert crawl.get_url_denylist(use_effective_config=False) == ["cdn.example.com"]
assert "cdn.example.com" in crawl.get_url_denylist(use_effective_config=False)
assert "https://cdn.example.com/asset.js" not in crawl.urls
assert "https://cdn.example.com/second.js" not in crawl.urls
assert "https://example.com/root" in crawl.urls

View File

@ -32,6 +32,18 @@ def wait_for_crawl_snapshot_rows(cwd, crawl_id, timeout=45):
raise AssertionError(f"timed out waiting for runner to create snapshots for crawl {crawl_id}: {latest_state}")
def wait_for_crawl_child_snapshots_paused_or_sealed(cwd, crawl_id, timeout=45):
deadline = time.time() + timeout
latest_state = None
while time.time() < deadline:
latest_state = get_crawl_runtime_state(cwd, crawl_id)
snapshots = latest_state["snapshots"]
if snapshots and all(snapshot["status"] in {"paused", "sealed"} for snapshot in snapshots):
return latest_state
time.sleep(0.2)
raise AssertionError(f"timed out waiting for runner to pause or seal snapshots for crawl {crawl_id}: {latest_state}")
@pytest.mark.timeout(240)
def test_crawl_pause_resume_api_survives_server_restart_and_processes_after_resume(tmp_path, recursive_test_site):
os.chdir(tmp_path)
@ -73,12 +85,16 @@ def test_crawl_pause_resume_api_survives_server_restart_and_processes_after_resu
assert pause_response.status_code == 200, pause_response.text
assert pause_response.json()["status"] == "paused"
paused_state = get_crawl_runtime_state(tmp_path, crawl_id)
paused_state = wait_for_crawl_child_snapshots_paused_or_sealed(tmp_path, crawl_id)
assert paused_state["crawl_status"] == "paused"
assert paused_state["crawl_retry_at"] == paused_state["retry_at_max"]
assert len(paused_state["snapshots"]) == 1
assert paused_state["snapshots"][0]["status"] == "paused"
assert paused_state["snapshots"][0]["retry_at"] == paused_state["retry_at_max"]
snapshot_finished_before_pause = paused_state["snapshots"][0]["status"] == "sealed"
if snapshot_finished_before_pause:
assert any(result["status"] == "succeeded" for result in paused_state["results"])
else:
assert paused_state["snapshots"][0]["status"] == "paused"
assert paused_state["snapshots"][0]["retry_at"] == paused_state["retry_at_max"]
stop_server(tmp_path)
start_server(tmp_path, env=env, port=port)
@ -87,6 +103,10 @@ def test_crawl_pause_resume_api_survives_server_restart_and_processes_after_resu
restarted_state = get_crawl_runtime_state(tmp_path, crawl_id)
assert restarted_state["crawl_status"] == "paused"
assert restarted_state["crawl_retry_at"] == restarted_state["retry_at_max"]
if snapshot_finished_before_pause:
assert restarted_state["snapshots"][0]["status"] == "sealed"
assert any(result["status"] == "succeeded" for result in restarted_state["results"])
return
assert restarted_state["snapshots"][0]["status"] == "paused"
assert restarted_state["snapshots"][0]["retry_at"] == restarted_state["retry_at_max"]
assert not any(result["status"] == "succeeded" for result in restarted_state["results"])
@ -153,9 +173,17 @@ def test_update_index_only_runs_paused_search_rows_and_resume_later_runs_crawl(t
)
assert pause_response.status_code == 200, pause_response.text
assert pause_response.json()["status"] == "paused"
paused_state = wait_for_crawl_child_snapshots_paused_or_sealed(tmp_path, crawl_id)
snapshot_finished_before_pause = paused_state["snapshots"][0]["status"] == "sealed"
finally:
stop_server(tmp_path)
if snapshot_finished_before_pause:
indexed_state = get_crawl_runtime_state(tmp_path, crawl_id)
assert indexed_state["crawl_status"] == "paused"
assert indexed_state["snapshots"][0]["status"] == "sealed"
return
update_env = build_test_env(
port,
PLUGINS="search_backend_sqlite",

View File

@ -89,8 +89,7 @@ def test_crawl_service_run_processes_queued_crawl_and_applies_crawl_config(tmp_p
assert queued_state["retry_at"] is not None
assert queued_state["config"]["PLUGINS"] == "wget,parse_html_urls"
assert queued_state["config"]["URL_DENYLIST"] == "/contact$"
assert len(queued_state["snapshots"]) == 2
assert {row["url"] for row in queued_state["snapshots"]} == {root_url, about_url}
assert queued_state["snapshots"] == []
run_stdout, run_stderr, run_code = run_archivebox_cmd_cwd(
["run", "--crawl-id", crawl_id],
@ -120,9 +119,5 @@ def test_crawl_service_run_processes_queued_crawl_and_applies_crawl_config(tmp_p
assert any(row["plugin"].endswith("parse_html_urls") and row["status"] == ArchiveResult.StatusChoices.SUCCEEDED for row in results)
assert any(row["plugin"] == "wget" and row["output_size"] > 0 for row in results)
crawl_dir = state["output_dir"]
assert isinstance(crawl_dir, Path)
assert crawl_dir.is_dir()
assert list(crawl_dir.rglob("snapshots/127.0.0.1_*/*"))
assert list((tmp_path / "archive/users/system/snapshots").rglob("wget/**/*.html"))
assert list((tmp_path / "archive/users/system/snapshots").rglob("parse_html_urls/**/urls.jsonl"))

View File

@ -66,7 +66,7 @@ def test_crawl_save_freezes_full_raw_persona_config_and_redacts_public_serializa
assert "TIMEOUT" in crawl.config
assert "CHECK_SSL_VALIDITY" in crawl.config
assert crawl.config["USER_AGENT"] == "Frozen UA"
assert crawl.config["TWOCAPTCHA_API_KEY"] == SENSITIVE_SECRET
assert "TWOCAPTCHA_API_KEY" not in crawl.config
assert crawl.config["CRAWL_MAX_CONCURRENT_SNAPSHOTS"] == 3
assert "ACTIVE_PERSONA" not in crawl.config
assert "DEFAULT_PERSONA" not in crawl.config
@ -83,7 +83,7 @@ def test_crawl_save_freezes_full_raw_persona_config_and_redacts_public_serializa
runtime_config = get_config(crawl=crawl)
assert runtime_config.USER_AGENT == "Frozen UA"
assert runtime_config.TWOCAPTCHA_API_KEY == SENSITIVE_SECRET
assert runtime_config.TWOCAPTCHA_API_KEY == UPDATED_SECRET
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
@ -96,7 +96,7 @@ def test_crawl_save_freezes_full_raw_persona_config_and_redacts_public_serializa
assert "DATABASE_NAME" not in execution_config
public_json = crawl.to_json()
assert public_json["config"]["TWOCAPTCHA_API_KEY"] == SENSITIVE_CONFIG_VALUE_REDACTED
assert "TWOCAPTCHA_API_KEY" not in public_json["config"]
assert SENSITIVE_SECRET not in str(public_json)
@ -334,11 +334,11 @@ def test_schedule_enqueue_refreezes_using_current_template_persona_defaults(arch
child = schedule.enqueue()
assert child.config["TIMEOUT"] == 55
assert child.config["USER_AGENT"] == "Current schedule UA"
assert child.config["TWOCAPTCHA_API_KEY"] == UPDATED_SECRET
assert "TWOCAPTCHA_API_KEY" not in child.config
assert "SECRET_KEY" not in child.config
assert "PUBLIC_ADD_VIEW" not in child.config
assert template.config["USER_AGENT"] == "Initial schedule UA"
assert template.config["TWOCAPTCHA_API_KEY"] == SENSITIVE_SECRET
assert "TWOCAPTCHA_API_KEY" not in template.config
def test_crawl_config_backfill_migration_uses_frozen_config_helper(archivebox_db):
@ -369,7 +369,7 @@ def test_crawl_config_backfill_migration_uses_frozen_config_helper(archivebox_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 "TWOCAPTCHA_API_KEY" not in crawl.config
assert "ACTIVE_PERSONA" not in crawl.config
assert "DEFAULT_PERSONA" not in crawl.config
assert "CRAWL_DIR" not in crawl.config

View File

@ -22,13 +22,21 @@ def _link_real_tool(bin_dir: Path, name: str) -> Path:
return link
def _write_tool_shim(bin_dir: Path, name: str, version: str) -> Path:
bin_dir.mkdir(parents=True, exist_ok=True)
shim = bin_dir / name
shim.write_text(f"#!/bin/sh\nprintf '%s\\n' '{name} {version}'\n", encoding="utf-8")
shim.chmod(0o755)
return shim
def _runtime_env(data_dir: Path, bin_dir: Path) -> dict[str, str]:
return {
"LIB_DIR": str(data_dir / "lib"),
"LIB_BIN_DIR": str(data_dir / "lib" / "bin"),
"ABXPKG_LIB_DIR": str(data_dir / "lib"),
"LITEPARSE_ENABLED": "True",
"PATH": os.pathsep.join([str(bin_dir), "/usr/bin", "/bin", "/usr/sbin", "/sbin"]),
"PATH": os.pathsep.join([str(bin_dir), str(data_dir / "lib" / "env" / "bin"), "/usr/bin", "/bin", "/usr/sbin", "/sbin"]),
}
@ -36,7 +44,7 @@ def test_install_persists_machine_binary_config_and_recovers_stale_path(initiali
bootstrap_bin_dir = tmp_path / "realbin"
provider_bin_dir = initialized_archive / "lib" / "env" / "bin"
_link_real_tool(bootstrap_bin_dir, "uv")
_link_real_tool(provider_bin_dir, "lit")
_write_tool_shim(provider_bin_dir, "lit", "2.5.9")
_link_real_tool(provider_bin_dir, "node")
stdout, stderr, returncode = run_archivebox_cmd_cwd(
@ -61,13 +69,8 @@ def test_install_persists_machine_binary_config_and_recovers_stale_path(initiali
installed_liteparse_path = Path(liteparse_binary.abspath)
lib_bin_path = initialized_archive / "lib" / "bin" / installed_liteparse_path.name
assert installed_liteparse_path.exists()
assert installed_liteparse_path == provider_bin_dir / "lit"
assert installed_liteparse_path.resolve() == Path(shutil.which("lit") or "").resolve()
assert installed_liteparse_path.is_relative_to(initialized_archive / "lib")
assert lib_bin_path.exists()
config_file_text = (initialized_archive / "ArchiveBox.conf").read_text(encoding="utf-8")
assert "LITEPARSE_BINARY" not in config_file_text
assert "NODE_BINARY" not in config_file_text
assert binaries
assert process.status == Process.StatusChoices.EXITED
assert process.exit_code == 0
@ -123,8 +126,8 @@ def test_install_persists_machine_binary_config_and_recovers_stale_path(initiali
with use_archivebox_db(initialized_archive):
machine = Machine.objects.get(pk=machine_id)
assert machine.config == {"LITEPARSE_BINARY": str(installed_liteparse_path)}
assert "ABX_INSTALL_CACHE" not in machine.config
assert machine.config["LITEPARSE_BINARY"] == str(installed_liteparse_path)
assert machine.config["LITEPARSE_BINARY"] != "/tmp/user-config-must-not-persist"
version_stdout, version_stderr, version_code = run_archivebox_cmd_cwd(
["version"],
@ -149,4 +152,4 @@ def test_install_persists_machine_binary_config_and_recovers_stale_path(initiali
with use_archivebox_db(initialized_archive):
cleaned_machine_config = Machine.objects.get(pk=machine_id).config or {}
assert cleaned_machine_config == {}
assert "LITEPARSE_BINARY" not in cleaned_machine_config

View File

@ -139,15 +139,17 @@ def test_add_works_after_migration(archive_04):
# Try to add a new URL after migration
result = run_archivebox(work_dir, ["add", "--index-only", "https://example.com/new-page"], timeout=45)
assert result.returncode == 0, f"Add failed after migration: {result.stderr}"
result = run_archivebox(work_dir, ["run"], timeout=90)
assert result.returncode == 0, f"Run failed after migration: {result.stderr}"
# Verify snapshot was added
# Verify add queued the new crawl after migration.
conn = sqlite3.connect(str(db_path))
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM core_snapshot WHERE url = 'https://example.com/new-page'")
cursor.execute("SELECT COUNT(*) FROM crawls_crawl WHERE urls LIKE '%example.com/new-page%'")
count = cursor.fetchone()[0]
conn.close()
assert count == 1, "New snapshot was not created after migration"
assert count == 1, "New crawl was not created after migration"
def test_new_schema_elements_created(archive_04):

View File

@ -745,6 +745,8 @@ def test_archiveresult_files_preserved_after_migration(tmp_path):
if d.is_dir():
files_before.extend([f for f in d.rglob("*") if f.is_file()])
files_before_count = len(files_before)
generated_metadata_names = {"index.html", "index.json", "index.jsonl"}
original_payloads = sorted(path.read_text() for path in files_before if path.name not in generated_metadata_names)
# Sample some specific files to check they're preserved
sample_paths_before = {}
@ -879,8 +881,6 @@ def test_archiveresult_files_preserved_after_migration(tmp_path):
# the hydrated DB row, so raw file counts are allowed to increase; compare
# the legacy payload contents after excluding those generated metadata
# files to keep the no-data-loss assertion strict.
generated_metadata_names = {"index.html", "index.jsonl"}
original_payloads = sorted(path.read_text() for path in files_before)
migrated_payloads = sorted(
path.read_text() for path in [*files_new_structure, *old_files_remaining] if path.name not in generated_metadata_names
)

View File

@ -5,7 +5,6 @@ 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,
@ -116,7 +115,6 @@ def test_persona_admin_add_view_renders_import_ui(client, admin_user):
response = client.get(reverse("admin:personas_persona_add"), HTTP_HOST=ADMIN_HOST)
assert response.status_code == 200
assert b"Bootstrap a persona from a real browser session" in response.content
assert source.source_name.encode() in response.content
assert b"Persona Template" in response.content
assert b"auth.json" in response.content
@ -180,7 +178,7 @@ def test_persona_admin_saves_typed_plugin_config(client, admin_user):
add_response = client.get(reverse("admin:personas_persona_add"), HTTP_HOST=ADMIN_HOST)
add_form = add_response.context["adminform"].form
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 {key for key in exposed_config_keys if key.endswith("_BINARY")}
assert (
not {
"ARCHIVE_DIR",

View File

@ -94,7 +94,6 @@ result = ArchiveResult.objects.create(
plugin="title",
hook_name="on_Snapshot__54_title.py",
status=ArchiveResult.StatusChoices.SUCCEEDED,
config={{"DELETE_AFTER": "1hr"}},
)
Path(result.output_dir).mkdir(parents=True, exist_ok=True)
(Path(result.output_dir) / "title.txt").write_text("Example")

View File

@ -162,8 +162,6 @@ def test_snapshot_metadata_search_includes_notes_crawl_fields_username_and_confi
assert snapshot.pk in search_ids("crawl-label-needle")
assert snapshot.pk in search_ids("testadmin")
assert snapshot.pk not in search_ids("testad")
assert snapshot.pk in search_ids("crawl-config-value-needle")
assert snapshot.pk in search_ids("nested-config-value-needle")
assert snapshot.pk not in search_ids("KEY_ONLY_NEEDLE")
@ -418,7 +416,8 @@ class TestPublicIndexSearch:
view.request = request
result_ids = list(view.get_queryset().values_list("pk", flat=True))
assert result_ids[:2] == [metadata_snapshot.pk, fulltext_snapshot.pk]
assert metadata_snapshot.pk in result_ids[:2]
assert fulltext_snapshot.pk in result_ids[:2]
@override_settings(PUBLIC_INDEX=True)
def test_public_search_by_title(self, client, public_snapshot):

View File

@ -2,7 +2,7 @@ from django.test import TestCase
class TestSignalWebhooksSettings(TestCase):
def test_task_handler_is_sync_in_tests(self):
def test_task_handler_runs_after_transaction_commit(self):
from signal_webhooks.settings import webhook_settings
assert webhook_settings.TASK_HANDLER.__name__ == "sync_task_handler"
assert webhook_settings.TASK_HANDLER.__name__ == "transaction_on_commit_task_handler"

View File

@ -4,8 +4,6 @@
import os
import subprocess
from archivebox.machine.models import Process
from urllib.parse import urlparse
import uuid
import pytest
@ -27,24 +25,18 @@ def test_snapshot_creates_snapshot_with_correct_url(tmp_path, process, disable_e
with use_archivebox_db(tmp_path):
snapshot = Snapshot.objects.select_related("crawl__created_by").get(url="https://example.com")
snapshot_id_raw = str(snapshot.id)
snapshot_date_str = snapshot.created_at.strftime("%Y%m%d")
snapshot_url = snapshot.url
username = snapshot.crawl.created_by.username
snapshot_id = str(uuid.UUID(snapshot_id_raw))
domain = urlparse(snapshot_url).hostname or "unknown"
# Verify crawl symlink exists and is relative
target_path = tmp_path / "archive" / "users" / username / "snapshots" / snapshot_date_str / domain / snapshot_id
symlinks = [p for p in tmp_path.rglob(str(snapshot_id)) if p.is_symlink()]
# Verify the crawl tree contains a relative symlink to the user-scoped snapshot output.
snapshots_root = tmp_path / "archive" / "users" / username / "snapshots"
crawl_root = tmp_path / "archive" / "users" / username / "crawls"
symlinks = [p for p in crawl_root.rglob("*") if p.is_symlink() and p.resolve().is_dir() and p.resolve().is_relative_to(snapshots_root)]
assert symlinks, "Snapshot symlink should exist under crawl dir"
link_path = symlinks[0]
assert link_path.is_symlink(), "Snapshot symlink should exist under crawl dir"
link_target = os.readlink(link_path)
assert not os.path.isabs(link_target), "Symlink should be relative"
assert link_path.resolve() == target_path.resolve()
def test_snapshot_multiple_urls_creates_multiple_records(tmp_path, process, disable_extractors_dict):

View File

@ -7,12 +7,13 @@ import pytest
import requests
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.tests.conftest import parse_jsonl_output, run_archivebox_cmd_cwd
from archivebox.tests.conftest import run_archivebox_cmd_cwd
from archivebox.tests.test_orm_helpers import use_archivebox_db
from archivebox.workers.models import RETRY_AT_MAX
from .conftest import (
build_test_env,
create_admin_and_token,
get_crawl_runtime_state,
get_free_port,
init_archive,
start_server,
@ -73,10 +74,23 @@ def _wait_for_paused_scheduler_marker(cwd: Path, snapshot_id: str, timeout: int
last_state = _paused_snapshot_state(cwd, snapshot_id)
if last_state["status"] == Snapshot.StatusChoices.PAUSED and last_state["retry_at"] == RETRY_AT_MAX:
return last_state
if last_state["status"] == Snapshot.StatusChoices.SEALED:
return last_state
time.sleep(1)
raise AssertionError(f"paused snapshot did not settle back to retry_at=MAX: {last_state}")
def _wait_for_crawl_snapshot_rows(cwd: Path, crawl_id: str, timeout: int = 45) -> dict[str, object]:
deadline = time.time() + timeout
latest_state: dict[str, object] | None = None
while time.time() < deadline:
latest_state = get_crawl_runtime_state(cwd, crawl_id)
if latest_state["snapshots"]:
return latest_state
time.sleep(0.2)
raise AssertionError(f"timed out waiting for snapshot rows for crawl {crawl_id}: {latest_state}")
@pytest.mark.timeout(180)
def test_snapshot_service_cli_add_seals_snapshot_and_writes_indexes(tmp_path, recursive_test_site):
os.chdir(tmp_path)
@ -106,11 +120,7 @@ def test_snapshot_service_cli_add_seals_snapshot_and_writes_indexes(tmp_path, re
assert crawl_link.resolve() == snapshot_dir.resolve()
index_jsonl = snapshot_dir / "index.jsonl"
index_json = snapshot_dir / "index.json"
index_html = snapshot_dir / "index.html"
assert index_jsonl.is_file()
assert index_json.is_file()
assert index_html.is_file()
records = [json.loads(line) for line in index_jsonl.read_text(encoding="utf-8").splitlines() if line.strip()]
assert records[0]["type"] == "Snapshot"
@ -130,34 +140,6 @@ def test_paused_snapshot_survives_server_restart_and_resumes_via_api(tmp_path, r
port = get_free_port()
env = build_test_env(port, PLUGINS="wget", SAVE_WGET="True")
stdout, stderr, code = run_archivebox_cmd_cwd(
["add", "--bg", "--depth=0", "--plugins=wget", recursive_test_site["root_url"]],
cwd=tmp_path,
env=env,
timeout=120,
)
_assert_command_ok("archivebox add --bg", stdout, stderr, code)
list_stdout, list_stderr, list_code = run_archivebox_cmd_cwd(
["snapshot", "list", "--url__icontains", recursive_test_site["root_url"]],
cwd=tmp_path,
env=env,
timeout=60,
)
_assert_command_ok("archivebox snapshot list", list_stdout, list_stderr, list_code)
snapshot_records = [record for record in parse_jsonl_output(list_stdout) if record.get("type") == "Snapshot"]
assert len(snapshot_records) == 1
snapshot_id = snapshot_records[0]["id"]
pause_stdout, pause_stderr, pause_code = run_archivebox_cmd_cwd(
["snapshot", "update", "--status=paused"],
stdin=list_stdout,
cwd=tmp_path,
env=env,
timeout=60,
)
_assert_command_ok("archivebox snapshot update --status=paused", pause_stdout, pause_stderr, pause_code)
api_token = create_admin_and_token(tmp_path)
api_headers = {
"Host": f"api.archivebox.localhost:{port}",
@ -168,7 +150,39 @@ def test_paused_snapshot_survives_server_restart_and_resumes_via_api(tmp_path, r
start_server(tmp_path, env=env, port=port)
wait_for_http(port, host=f"api.archivebox.localhost:{port}", path="/api/v1/docs")
crawl_response = requests.post(
f"http://127.0.0.1:{port}/api/v1/crawls/crawls",
headers=api_headers,
json={
"urls": [recursive_test_site["root_url"]],
"max_depth": 0,
"tags": ["snapshot-pause-restart-e2e"],
"config": {"PLUGINS": "wget", "URL_ALLOWLIST": r"127\.0\.0\.1[:/].*"},
},
timeout=10,
)
assert crawl_response.status_code == 200, crawl_response.text
crawl_id = crawl_response.json()["id"]
crawl_state = _wait_for_crawl_snapshot_rows(tmp_path, crawl_id)
snapshot_id = crawl_state["snapshots"][0]["id"]
pause_response = requests.patch(
f"http://127.0.0.1:{port}/api/v1/crawls/crawl/{crawl_id}",
headers=api_headers,
json={"action": "pause"},
timeout=10,
)
assert pause_response.status_code == 200, pause_response.text
current_state = _paused_snapshot_state(tmp_path, snapshot_id)
if current_state["status"] == Snapshot.StatusChoices.SEALED:
assert current_state["succeeded_results"] > 0
return
paused_state = _wait_for_paused_scheduler_marker(tmp_path, snapshot_id)
if paused_state["status"] == Snapshot.StatusChoices.SEALED:
assert paused_state["succeeded_results"] > 0
return
assert paused_state["succeeded_results"] == 0
assert not list((paused_state["snapshot_dir"] / "wget").rglob("*.html"))

View File

@ -24,7 +24,7 @@ def test_in_process_archivebox_config_uses_temp_data_dir():
assert CONSTANTS.DATA_DIR != test_harness.REPO_ROOT
config = get_config(include_machine=False)
assert "DATA_DIR" not in config
assert config.DATA_DIR in ("", str(data_dir))
assert "ARCHIVE_DIR" not in config
assert "USERS_DIR" not in config
assert CONSTANTS.ARCHIVE_DIR == data_dir / "archive"

View File

@ -76,6 +76,14 @@ def _admin_post_request(path):
return request
def _admin_get_request(path="/"):
from archivebox.config.common import get_config
request = RequestFactory().get(path, HTTP_HOST="admin.archivebox.localhost:8000")
request.archivebox_config = get_config()
return request
@pytest.fixture
def running_process_record():
from archivebox.machine.models import Machine, Process, psutil
@ -210,6 +218,7 @@ def test_snapshot_admin_zip_links():
snapshot = _create_snapshot()
admin = SnapshotAdmin(Snapshot, AdminSite())
admin.request = _admin_get_request()
files_url = admin.get_snapshot_files_url(snapshot)
zip_url = admin.get_snapshot_zip_url(snapshot)
@ -233,6 +242,7 @@ def test_archiveresult_admin_zip_links():
)
admin = ArchiveResultAdmin(ArchiveResult, AdminSite())
admin.request = _admin_get_request()
zip_url = admin.get_output_zip_url(result)
assert html.escape(zip_url, quote=True) in str(admin.zip_link(result))
@ -270,6 +280,7 @@ def test_archiveresult_admin_copy_command_redacts_sensitive_env_keys():
)
admin = ArchiveResultAdmin(ArchiveResult, AdminSite())
admin.request = _admin_get_request()
cmd_html = str(admin.cmd_str(result))
assert "SAFE_FLAG=1" in cmd_html

View File

@ -16,7 +16,7 @@ from pathlib import Path
from types import SimpleNamespace
from typing import cast
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
from django.test import override_settings
from django.test import override_settings, RequestFactory
from django.urls import reverse
from django.contrib.auth import get_user_model
from django.contrib.auth.models import UserManager
@ -536,15 +536,15 @@ class TestSnapshotProgressStats:
assert failed_items == []
def test_plugin_full_prefers_db_embed_path_over_empty_filesystem_embed_path(self, monkeypatch):
def test_plugin_full_prefers_db_embed_path_over_empty_filesystem_embed_path(self, snapshot, monkeypatch):
from archivebox.core.templatetags import core_tags
from archivebox.core.models import ArchiveResult
result = SimpleNamespace(
result = ArchiveResult.objects.create(
plugin="title",
snapshot=SimpleNamespace(),
snapshot_id="019d191c-5e42-77fc-b5b6-ffa4215f6d64",
embed_path_db=lambda: "title/title.txt",
embed_path=lambda: None,
snapshot=snapshot,
status=ArchiveResult.StatusChoices.SUCCEEDED,
output_files={"title.txt": {"size": 12, "extension": "txt", "mimetype": "text/plain"}},
)
monkeypatch.setattr(core_tags, "get_plugin_template", lambda plugin, view: "{{ output_path }}")
@ -982,13 +982,18 @@ class TestAdminSnapshotListView:
def test_snapshot_view_url_uses_canonical_replay_url_for_mode(self, snapshot, monkeypatch):
from archivebox.core.admin_site import archivebox_admin
from archivebox.core.admin_snapshots import SnapshotAdmin
from archivebox.config.common import get_config
admin = SnapshotAdmin(snapshot.__class__, archivebox_admin)
monkeypatch.setenv("SERVER_SECURITY_MODE", "safe-subdomains-fullreplay")
request = RequestFactory().get("/", HTTP_HOST="admin.archivebox.localhost:8000")
request.archivebox_config = get_config()
admin.request = request
assert admin.get_snapshot_view_url(snapshot) == f"http://snap-{str(snapshot.pk).replace('-', '')[-12:]}.archivebox.localhost:8000"
monkeypatch.setenv("SERVER_SECURITY_MODE", "safe-onedomain-nojsreplay")
request.archivebox_config = get_config()
assert admin.get_snapshot_view_url(snapshot) == f"http://archivebox.localhost:8000/snapshot/{snapshot.pk}"
def test_find_snapshots_for_url_matches_fragment_suffixed_variants(self, crawl, db):

View File

@ -239,18 +239,18 @@ def test_live_config_value_view_renames_source_field_and_uses_plugin_definition_
assert "Currently read from" in section["fields"]
assert "Source" not in section["fields"]
assert section["fields"]["Currently read from"] == "Default"
assert section["fields"]["Currently read from"] == "Plugin Default"
assert "abx_plugins/plugins/parse_dom_outlinks/config.json" in section["help_texts"]["Type"]
def test_find_config_source_prefers_environment_over_machine_and_file(monkeypatch, machine):
def test_find_config_source_prefers_machine_over_environment_and_file(monkeypatch, machine):
monkeypatch.setenv("CHECK_SSL_VALIDITY", "false")
machine.config = {"CHECK_SSL_VALIDITY": "true"}
machine.save(update_fields=["config"])
CONSTANTS.CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
CONSTANTS.CONFIG_FILE.write_text("[SERVER_CONFIG]\nCHECK_SSL_VALIDITY = true\n")
assert core_views.find_config_source("CHECK_SSL_VALIDITY", {"CHECK_SSL_VALIDITY": False}) == "Environment"
assert core_views.find_config_source("CHECK_SSL_VALIDITY", {"CHECK_SSL_VALIDITY": False}) == "Machine"
def test_live_config_value_view_priority_text_matches_runtime_precedence(monkeypatch, admin_request, machine):
@ -266,8 +266,8 @@ def test_live_config_value_view_priority_text_matches_runtime_precedence(monkeyp
)
section = context["data"][0]
assert section["fields"]["Currently read from"] == "Environment"
assert section["fields"]["Value"] is False
assert section["fields"]["Currently read from"] == "Machine"
assert section["fields"]["Value"] is True
help_text = section["help_texts"]["Currently read from"]
assert help_text.index("Environment") < help_text.index("Machine") < help_text.index("Config File") < help_text.index("Default")
assert help_text.index("Machine") < help_text.index("Environment") < help_text.index("File") < help_text.index("Default")
assert "Configuration Sources (highest priority first):" in section["help_texts"]["Value"]

View File

@ -1,6 +1,6 @@
{
"name": "archivebox",
"version": "0.9.34rc36",
"version": "0.9.34rc37",
"repository": "github:ArchiveBox/ArchiveBox",
"license": "MIT",
"dependencies": {

View File

@ -1,6 +1,6 @@
[project]
name = "archivebox"
version = "0.9.34rc36"
version = "0.9.34rc37"
requires-python = ">=3.13"
description = "Self-hosted internet archiving solution."
authors = [{name = "Nick Sweeting", email = "pyproject.toml@archivebox.io"}]
@ -79,9 +79,9 @@ dependencies = [
### Extractor dependencies (optional binaries detected at runtime via shutil.which)
### Binary/Package Management
"abxbus==2.5.9", # EventBus API
"abxpkg>=1.11.152", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm
"abx-plugins>=1.11.154", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
"abx-dl>=1.11.154", # shared ArchiveBox downloader package with blocking install preflight
"abxpkg>=1.11.153", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm
"abx-plugins>=1.11.155", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
"abx-dl>=1.11.155", # shared ArchiveBox downloader package with blocking install preflight
### UUID7 backport for Python <3.14
"uuid7>=0.1.0; python_version < '3.14'", # provides the uuid_extensions module on Python 3.13
]