diff --git a/Dockerfile b/Dockerfile index 22addaa1..0d32a44f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 {} + \ diff --git a/archivebox/cli/archivebox_process.py b/archivebox/cli/archivebox_process.py index 4df39c75..d307b22d 100644 --- a/archivebox/cli/archivebox_process.py +++ b/archivebox/cli/archivebox_process.py @@ -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 = {} diff --git a/archivebox/config/common.py b/archivebox/config/common.py index f48c925f..8db8002d 100644 --- a/archivebox/config/common.py +++ b/archivebox/config/common.py @@ -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) diff --git a/archivebox/core/admin_archiveresults.py b/archivebox/core/admin_archiveresults.py index 2b7eb18d..529f108b 100644 --- a/archivebox/core/admin_archiveresults.py +++ b/archivebox/core/admin_archiveresults.py @@ -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''' - {result.process.pid or "-"} + title="View process">{process.pid or "-"} ''' machine_display = "-" - if result.process_id and result.process and result.process.machine_id: + if process and process.machine_id: machine_display = f''' - {result.process.machine.hostname} + title="View machine">{process.machine.hostname} ''' # 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( '{}', - 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( '{} {}', reverse("admin:machine_machine_change", args=[machine.id]), diff --git a/archivebox/core/migrations/0039_alter_archiveresult_process.py b/archivebox/core/migrations/0039_alter_archiveresult_process.py new file mode 100644 index 00000000..7277b6d3 --- /dev/null +++ b/archivebox/core/migrations/0039_alter_archiveresult_process.py @@ -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", + ), + ), + ] diff --git a/archivebox/core/models.py b/archivebox/core/models.py index 4aaad2dd..e8612b18 100755 --- a/archivebox/core/models.py +++ b/archivebox/core/models.py @@ -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. diff --git a/archivebox/core/views.py b/archivebox/core/views.py index 052238e8..6fca210e 100644 --- a/archivebox/core/views.py +++ b/archivebox/core/views.py @@ -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 diff --git a/archivebox/tests/test_admin_links.py b/archivebox/tests/test_admin_links.py index ef5e8999..dea81d52 100644 --- a/archivebox/tests/test_admin_links.py +++ b/archivebox/tests/test_admin_links.py @@ -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 diff --git a/archivebox/tests/test_persona_runtime.py b/archivebox/tests/test_persona_runtime.py index d1a4e3d3..c1efb740 100644 --- a/archivebox/tests/test_persona_runtime.py +++ b/archivebox/tests/test_persona_runtime.py @@ -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" diff --git a/etc/package.json b/etc/package.json index 8f17346b..b116e7cc 100644 --- a/etc/package.json +++ b/etc/package.json @@ -1,6 +1,6 @@ { "name": "archivebox", - "version": "0.9.32rc25", + "version": "0.9.32rc26", "repository": "github:ArchiveBox/ArchiveBox", "license": "MIT", "dependencies": { diff --git a/pyproject.toml b/pyproject.toml index 2f985ca5..3223a3c7 100755 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 ]