release: archivebox 0.9.32rc26

This commit is contained in:
Nick Sweeting 2026-05-27 13:23:23 -07:00
parent a21765f366
commit e8bb2351d9
No known key found for this signature in database
11 changed files with 280 additions and 80 deletions

View File

@ -312,7 +312,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$T
echo "[+] Installing plugin runtime dependencies into $LIB_DIR..." \
&& apt-get update -qq \
&& if [ "$TARGETARCH" = "arm64" ]; then \
abxpkg install --binproviders=playwright --bin-dir="$LIB_DIR/env/bin" chromium; \
abxpkg install --binproviders=playwright --bin-dir="$LIB_DIR/env/bin" --overrides='{"playwright":{"install_args":["chrome-beta"]}}' chromium; \
fi \
&& PUID=0 PGID=0 abx-dl plugins --install \
&& find "$LIB_DIR" "$DATA_DIR"/personas -type d -name __pycache__ -prune -exec rm -rf {} + \

View File

@ -54,7 +54,7 @@ def list_processes(
is_tty = sys.stdout.isatty()
queryset = Process.objects.all().select_related("binary", "machine").order_by("-start_ts")
queryset = Process.objects.all().select_related("binary", "machine").order_by("-started_at", "-created_at")
# Apply filters
filter_kwargs = {}

View File

@ -483,15 +483,16 @@ def get_config(
Priority (highest to lowest):
1. Explicit overrides
2. Per-snapshot config and output path
3. Per-crawl config and output path
4. Per-user config
5. Per-persona derived config
6. Current machine derived config
7. Environment variables
8. Config file (ArchiveBox.conf)
9. Plugin schema defaults
10. Core config defaults
2. Per-ArchiveResult config
3. Per-snapshot config and output path
4. Per-crawl config and output path
5. Per-user config
6. Per-persona derived config
7. Current machine derived config
8. Environment variables
9. Config file (ArchiveBox.conf)
10. Plugin schema defaults
11. Core config defaults
"""
if snapshot is None and archiveresult is not None:
snapshot = archiveresult.snapshot
@ -559,6 +560,9 @@ def get_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)
if overrides:
scope_overrides.update(overrides)

View File

@ -34,7 +34,7 @@ from archivebox.core.models import ArchiveResult, Snapshot
def _get_replay_source_url(result: ArchiveResult) -> str:
process = getattr(result, "process", None)
process = result.process_record
return str(getattr(process, "url", None) or result.snapshot.url or "")
@ -134,20 +134,21 @@ def render_archiveresults_list(archiveresults_qs, limit=50, config=None):
# Format timestamp
end_time = result.end_ts.strftime("%Y-%m-%d %H:%M:%S") if result.end_ts else "-"
process = result.process_record
process_display = "-"
if result.process_id and result.process:
if process:
process_display = f'''
<a href="{reverse("admin:machine_process_change", args=[result.process_id])}"
<a href="{reverse("admin:machine_process_change", args=[process.id])}"
style="color: #2563eb; text-decoration: none; font-family: ui-monospace, monospace; font-size: 12px;"
title="View process">{result.process.pid or "-"}</a>
title="View process">{process.pid or "-"}</a>
'''
machine_display = "-"
if result.process_id and result.process and result.process.machine_id:
if process and process.machine_id:
machine_display = f'''
<a href="{reverse("admin:machine_machine_change", args=[result.process.machine_id])}"
<a href="{reverse("admin:machine_machine_change", args=[process.machine_id])}"
style="color: #2563eb; text-decoration: none; font-size: 12px;"
title="View machine">{result.process.machine.hostname}</a>
title="View machine">{process.machine.hostname}</a>
'''
# Truncate output for display
@ -660,20 +661,22 @@ class ArchiveResultAdmin(BaseModelAdmin):
@admin.display(description="Process", ordering="process__pid")
def process_link(self, result):
if not result.process_id:
process = result.process_record
if not process:
return "-"
process_label = result.process.pid if result.process and result.process.pid else "-"
process_label = process.pid or "-"
return format_html(
'<a href="{}"><code>{}</code></a>',
reverse("admin:machine_process_change", args=[result.process_id]),
reverse("admin:machine_process_change", args=[process.id]),
process_label,
)
@admin.display(description="Machine", ordering="process__machine__hostname")
def machine_link(self, result):
if not result.process_id or not result.process or not result.process.machine_id:
process = result.process_record
if not process or not process.machine_id:
return "-"
machine = result.process.machine
machine = process.machine
return format_html(
'<a href="{}"><code>{}</code> {}</a>',
reverse("admin:machine_machine_change", args=[machine.id]),

View File

@ -0,0 +1,26 @@
# Generated by Django 6.0.5 on 2026-05-27 20:11
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0038_snapshot_progress_idx"),
("machine", "0015_process_progress_indexes"),
]
operations = [
migrations.AlterField(
model_name="archiveresult",
name="process",
field=models.OneToOneField(
blank=True,
help_text="Process execution details for this archive result",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="archiveresult",
to="machine.process",
),
),
]

View File

@ -1341,15 +1341,16 @@ class Snapshot(ModelWithOutputDir, ModelWithConfig, ModelWithNotes, ModelWithHea
# Write ArchiveResult records with their associated Binary and Process
# Use select_related to optimize queries
for ar in self.archiveresult_set.select_related("process__binary").order_by("start_ts"):
process = ar.process_record
# Write Binary record if not already written
if ar.process and ar.process.binary and ar.process.binary_id not in binaries_seen:
binaries_seen.add(ar.process.binary_id)
f.write(json.dumps(ar.process.binary.to_json()) + "\n")
if process and process.binary and process.binary_id not in binaries_seen:
binaries_seen.add(process.binary_id)
f.write(json.dumps(process.binary.to_json()) + "\n")
# Write Process record if not already written
if ar.process and ar.process_id not in processes_seen:
processes_seen.add(ar.process_id)
f.write(json.dumps(ar.process.to_json()) + "\n")
if process and process.id not in processes_seen:
processes_seen.add(process.id)
f.write(json.dumps(process.to_json()) + "\n")
# Write ArchiveResult record
f.write(json.dumps(ar.to_json()) + "\n")
@ -3065,7 +3066,7 @@ class ArchiveResult(ModelWithOutputDir, ModelWithConfig, ModelWithNotes):
# Added POST-v0.9.0, will be added in a separate migration
process = models.OneToOneField(
"machine.Process",
on_delete=models.PROTECT,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="archiveresult",
@ -3137,12 +3138,15 @@ class ArchiveResult(ModelWithOutputDir, ModelWithConfig, ModelWithNotes):
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
if self.process_id:
record["process_id"] = str(self.process_id)
process = self.process_record
if process:
record["process_id"] = str(process.id)
return record
@staticmethod
@ -3607,40 +3611,56 @@ class ArchiveResult(ModelWithOutputDir, ModelWithConfig, ModelWithNotes):
# these properties provide seamless access to Process data through ArchiveResult
# Uncommented after migration 3 completed - properties now active
@property
def process_record(self):
if not self.process_id:
return None
try:
return self.process
except ObjectDoesNotExist:
return None
@property
def pwd(self) -> str:
"""Working directory (from Process)."""
return self.process.pwd if self.process_id else ""
"""Working directory, derived from the snapshot/plugin path if the Process row is gone."""
process = self.process_record
return process.pwd if process and process.pwd else str(self.output_dir)
@property
def cmd(self) -> list:
"""Command array (from Process)."""
return self.process.cmd if self.process_id else []
process = self.process_record
return process.cmd if process else []
@property
def cmd_version(self) -> str:
"""Command version (from Process.binary)."""
return self.process.cmd_version if self.process_id else ""
process = self.process_record
return process.cmd_version if process else ""
@property
def binary(self):
"""Binary FK (from Process)."""
return self.process.binary if self.process_id else None
process = self.process_record
return process.binary if process else None
@property
def iface(self):
"""Network interface FK (from Process)."""
return self.process.iface if self.process_id else None
process = self.process_record
return process.iface if process else None
@property
def machine(self):
"""Machine FK (from Process)."""
return self.process.machine if self.process_id else None
process = self.process_record
return process.machine if process else None
@property
def timeout(self) -> int:
"""Timeout in seconds (from Process)."""
return self.process.timeout if self.process_id else 120
process = self.process_record
return process.timeout if process else 120
def save_search_index(self):
pass
@ -3676,8 +3696,9 @@ class ArchiveResult(ModelWithOutputDir, ModelWithConfig, ModelWithNotes):
# Read and parse JSONL output from stdout.log
stdout_file = plugin_dir / "stdout.log"
records = []
if self.process_id and self.process:
records = extract_records_from_process(self.process)
process = self.process_record
if process:
records = extract_records_from_process(process)
if not records:
stdout = stdout_file.read_text() if stdout_file.exists() else ""
@ -3703,9 +3724,9 @@ class ArchiveResult(ModelWithOutputDir, ModelWithConfig, ModelWithNotes):
# Update cmd fields
if hook_data.get("cmd"):
if self.process_id:
self.process.cmd = hook_data["cmd"]
self.process.save()
if process:
process.cmd = hook_data["cmd"]
process.save()
self._set_binary_from_cmd(hook_data["cmd"])
# Note: cmd_version is derived from binary.version, not stored on Process
else:
@ -3718,7 +3739,7 @@ class ArchiveResult(ModelWithOutputDir, ModelWithConfig, ModelWithNotes):
except Exception:
pass
if is_background or (self.process_id and self.process and self.process.exit_code == 0):
if is_background or (process and process.exit_code == 0):
self.status = self.StatusChoices.SKIPPED
self.output_str = "Hook did not output ArchiveResult record"
else:
@ -3822,9 +3843,10 @@ class ArchiveResult(ModelWithOutputDir, ModelWithConfig, ModelWithNotes):
).first()
if binary:
if self.process_id:
self.process.binary = binary
self.process.save()
process = self.process_record
if process:
process.binary = binary
process.save()
return
# Fallback: match by binary name
@ -3835,9 +3857,10 @@ class ArchiveResult(ModelWithOutputDir, ModelWithConfig, ModelWithNotes):
).first()
if binary:
if self.process_id:
self.process.binary = binary
self.process.save()
process = self.process_record
if process:
process.binary = binary
process.save()
def _url_passes_filters(self, url: str) -> bool:
"""Check if URL passes URL_ALLOWLIST and URL_DENYLIST config filters.

View File

@ -917,13 +917,13 @@ class PublicIndexView(ListView):
def get_paginate_by(self, queryset):
runtime_config = getattr(self, "runtime_config", None)
if runtime_config is None:
self.runtime_config = runtime_config = get_config()
self.runtime_config = runtime_config = _get_request_config(self.request, resolve_plugins=True)
return runtime_config.SNAPSHOTS_PER_PAGE
def get_context_data(self, **kwargs):
runtime_config = getattr(self, "runtime_config", None)
if runtime_config is None:
self.runtime_config = runtime_config = get_config()
self.runtime_config = runtime_config = _get_request_config(self.request, resolve_plugins=True)
context = {
**super().get_context_data(**kwargs),
"VERSION": VERSION,
@ -990,7 +990,7 @@ class PublicIndexView(ListView):
def get(self, *args, **kwargs):
if self.request.user.is_authenticated:
return redirect("/admin/core/snapshot/")
if get_config().PUBLIC_INDEX:
if _get_request_config(self.request).PUBLIC_INDEX:
response = super().get(*args, **kwargs)
return response
else:
@ -1012,7 +1012,7 @@ class AddView(UserPassesTestMixin, FormView):
return super().get_initial()
def test_func(self):
return get_config().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:
user = self.request.user
@ -1032,7 +1032,8 @@ class AddView(UserPassesTestMixin, FormView):
def get_context_data(self, **kwargs):
from archivebox.personas.models import Persona
required_search_plugin = f"search_backend_{get_config().SEARCH_BACKEND_ENGINE}".strip()
request_config = _get_request_config(self.request, resolve_plugins=True)
required_search_plugin = f"search_backend_{request_config.SEARCH_BACKEND_ENGINE}".strip()
plugin_configs = discover_plugin_configs()
sensitive_keys = {
str(config_key)
@ -1062,7 +1063,7 @@ class AddView(UserPassesTestMixin, FormView):
# We can't just call request.build_absolute_uri in the template, because it would include query parameters
"absolute_add_path": self.request.build_absolute_uri(self.request.path),
"VERSION": VERSION,
"FOOTER_INFO": get_config().FOOTER_INFO,
"FOOTER_INFO": request_config.FOOTER_INFO,
"required_search_plugin": required_search_plugin,
"plugin_dependency_map_json": json.dumps(plugin_dependency_map, sort_keys=True),
"persona_config_map_json": json.dumps(persona_config_map, sort_keys=True, default=str),
@ -1820,6 +1821,7 @@ def live_progress_view(request):
for ar in sorted(snapshot_results, key=plugin_sort_key):
status = ar.status
process = ar.process_record
progress_value = 0
if status in (
ArchiveResult.StatusChoices.SUCCEEDED,
@ -1829,8 +1831,8 @@ def live_progress_view(request):
):
progress_value = 100
elif status == ArchiveResult.StatusChoices.STARTED:
started_at = ar.start_ts or (ar.process.started_at if ar.process_id and ar.process else None)
timeout = ar.process.timeout if ar.process_id and ar.process else 120
started_at = ar.start_ts or (process.started_at if process else None)
timeout = process.timeout if process else 120
if started_at and timeout:
elapsed = max(0.0, (now - started_at).total_seconds())
progress_value = int(min(99, max(1, (elapsed / float(timeout)) * 100)))
@ -1849,21 +1851,21 @@ def live_progress_view(request):
"hook_name": hook_name,
"phase": phase,
"status": status,
"process_id": str(ar.process_id) if ar.process_id else None,
"process_id": str(process.id) if process else None,
"admin_url": f"/admin/core/archiveresult/{ar.id}/change/",
}
output_path = archiveresult_output_path(ar)
if output_path:
plugin_payload["output_path"] = output_path
plugin_payload["output_url"] = snapshot_view_url(snapshot, output_path)
if status == ArchiveResult.StatusChoices.STARTED and ar.process_id and ar.process:
plugin_payload["pid"] = ar.process.pid
if status == ArchiveResult.StatusChoices.STARTED and process:
plugin_payload["pid"] = process.pid
if status == ArchiveResult.StatusChoices.STARTED:
plugin_payload["progress"] = progress_value
plugin_payload["timeout"] = ar.process.timeout if ar.process_id and ar.process else 120
plugin_payload["timeout"] = process.timeout if process else 120
plugin_payload["source"] = "archiveresult"
all_plugins.append(plugin_payload)
seen_plugin_keys.add(str(ar.process_id) if ar.process_id else f"{ar.plugin}:{hook_name}")
seen_plugin_keys.add(str(process.id) if process else f"{ar.plugin}:{hook_name}")
for proc_payload, proc_started_at in process_records_by_snapshot.get(str(snapshot["id"]), []):
if not is_current_run_timestamp(proc_started_at, snapshot_run_started_at):
@ -2158,15 +2160,7 @@ def live_config_list_view(request: HttpRequest, **kwargs) -> TableContext:
assert getattr(request.user, "is_superuser", False), "Must be a superuser to view configuration settings."
# Get merged config that includes Machine.config overrides
try:
from archivebox.machine.models import Machine
Machine.current()
merged_config = get_config()
except Exception:
# Fallback if Machine model not available
merged_config = get_config()
merged_config = get_config()
rows = {
"Section": [],
@ -2231,7 +2225,6 @@ def live_config_value_view(request: HttpRequest, key: str, **kwargs) -> ItemCont
assert getattr(request.user, "is_superuser", False), "Must be a superuser to view configuration settings."
# Get merged config
merged_config = get_config()
# Determine all sources for this config value

View File

@ -96,6 +96,69 @@ def test_archiveresult_admin_links_plugin_and_process():
assert f"/admin/machine/process/{process.id}/change" in process_html
def test_deleting_binary_and_process_records_preserves_results():
from archivebox.core.admin_archiveresults import ArchiveResultAdmin, build_abx_dl_replay_command, render_archiveresults_list
from archivebox.core.models import ArchiveResult
from archivebox.machine.admin import ProcessAdmin
from archivebox.machine.models import Binary, Process
snapshot = _create_snapshot()
machine = _create_machine()
binary = Binary.objects.create(
machine=machine,
name="wget",
abspath="/usr/bin/wget",
version="1.21.2",
binprovider="env",
binproviders="env",
status=Binary.StatusChoices.INSTALLED,
)
process = Process.objects.create(
machine=machine,
binary=binary,
process_type=Process.TypeChoices.HOOK,
pwd=str(snapshot.output_dir / "wget"),
cmd=["/tmp/on_Snapshot__06_wget.finite.bg.py", "--url=https://example.com"],
status=Process.StatusChoices.EXITED,
)
result = ArchiveResult.objects.create(
snapshot=snapshot,
plugin="wget",
hook_name="on_Snapshot__06_wget.finite.bg.py",
process=process,
status=ArchiveResult.StatusChoices.SUCCEEDED,
)
binary.delete()
process.refresh_from_db()
assert process.binary_id is None
assert process.cmd_version == ""
assert process.bin_abspath == ""
assert "binary_id" not in process.to_json()
assert ProcessAdmin(Process, AdminSite()).binary_link(process) == "-"
process.delete()
result.refresh_from_db()
assert result.process_id is None
assert ArchiveResult.objects.filter(id=result.id).exists()
assert result.pwd == str(result.output_dir)
assert result.cmd == []
assert result.cmd_version == ""
assert result.binary is None
assert result.iface is None
assert result.machine is None
assert result.timeout == 120
result_json = result.to_json()
assert result_json["pwd"] == str(result.output_dir)
assert "process_id" not in result_json
admin = ArchiveResultAdmin(ArchiveResult, AdminSite())
assert admin.process_link(result) == "-"
assert admin.machine_link(result) == "-"
assert "cd " in build_abx_dl_replay_command(result)
assert "wget" in render_archiveresults_list(ArchiveResult.objects.filter(id=result.id))
def test_snapshot_admin_zip_links():
from archivebox.core.admin_snapshots import SnapshotAdmin
from archivebox.core.models import Snapshot

View File

@ -217,3 +217,91 @@ def test_get_config_raises_for_missing_persona_id(initialized_archive):
payload = json.loads(stdout.strip().splitlines()[-1])
assert payload["raised"] is True
assert "references missing Persona" in payload["message"]
def test_get_config_resolves_parent_scopes_when_only_archiveresult_is_passed(initialized_archive):
script = textwrap.dedent(
"""
import json
import os
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'archivebox.core.settings')
os.environ['TIMEOUT'] = '22'
os.environ['CHROME_BINARY'] = 'env-chrome'
import django
django.setup()
from archivebox.config import CONSTANTS
from archivebox.config.common import get_config
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.crawls.models import Crawl
from archivebox.machine.models import Machine
from archivebox.personas.models import Persona
CONSTANTS.CONFIG_FILE.write_text('[ARCHIVING_CONFIG]\\nTIMEOUT=11\\nCHROME_BINARY=file-chrome\\n')
machine = Machine.current()
machine.config = {'CHROME_BINARY': 'machine-chrome'}
machine.save(update_fields=['config'])
persona = Persona.objects.create(
name='StackPersona',
config={'TIMEOUT': 33, 'CHROME_BINARY': 'persona-chrome'},
)
persona.ensure_dirs()
crawl = Crawl.objects.create(
urls='https://example.com',
persona_id=persona.id,
config={'TIMEOUT': 44, 'CHROME_BINARY': 'crawl-chrome'},
)
snapshot = Snapshot.objects.create(
url='https://example.com',
crawl=crawl,
config={'TIMEOUT': 55, 'CHROME_BINARY': 'snapshot-chrome'},
)
result = ArchiveResult.objects.create(
snapshot=snapshot,
plugin='title',
config={'TIMEOUT': 66, 'CHROME_BINARY': 'archiveresult-chrome'},
)
env_config = get_config(include_machine=False)
machine_config = get_config(machine=machine)
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'})
print(json.dumps({
'env': [env_config.TIMEOUT, env_config.CHROME_BINARY],
'machine': [machine_config.TIMEOUT, machine_config.CHROME_BINARY],
'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),
'expected_snap_dir': str(snapshot.output_dir),
'crawl_dir': str(result_config.CRAWL_DIR),
'expected_crawl_dir': str(crawl.output_dir),
'active_persona': result_config.ACTIVE_PERSONA,
}, default=str))
""",
)
stdout, stderr, code = run_python_cwd(script, cwd=initialized_archive, timeout=60)
assert code == 0, stderr
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["snap_dir"] == payload["expected_snap_dir"]
assert payload["crawl_dir"] == payload["expected_crawl_dir"]
assert payload["active_persona"] == "StackPersona"

View File

@ -1,6 +1,6 @@
{
"name": "archivebox",
"version": "0.9.32rc25",
"version": "0.9.32rc26",
"repository": "github:ArchiveBox/ArchiveBox",
"license": "MIT",
"dependencies": {

View File

@ -1,6 +1,6 @@
[project]
name = "archivebox"
version = "0.9.32rc25"
version = "0.9.32rc26"
requires-python = ">=3.13"
description = "Self-hosted internet archiving solution."
authors = [{name = "Nick Sweeting", email = "pyproject.toml@archivebox.io"}]
@ -80,9 +80,9 @@ dependencies = [
### Extractor dependencies (optional binaries detected at runtime via shutil.which)
### Binary/Package Management
"abxbus==2.5.7", # EventBus API
"abxpkg>=1.11.10", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm
"abx-plugins>=1.11.13", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
"abx-dl>=1.11.13", # shared ArchiveBox downloader package with blocking install preflight
"abxpkg>=1.11.11", # for: detecting, versioning, and installing binaries via apt/brew/pip/npm
"abx-plugins>=1.11.14", # shared ArchiveBox plugin package with Chrome/Puppeteer dependency wiring
"abx-dl>=1.11.14", # 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
]