Compare commits

..
Author SHA1 Message Date
Nick Sweeting 122bd0cc2e fix archivebox add 2025-12-29 02:58:49 -08:00
1108 changed files with 37601 additions and 197397 deletions

No files matched your search

+2 -64
View File
@@ -1,63 +1,25 @@
.DS_Store
._*
/.heartbeat.json
/package.json
/package-lock.json
__pycache__
**/__pycache__
*.pyc
*.pyo
*.py[cod]
*$py.class
__pycache__/
.mypy_cache/
.pytest_cache/
.ruff_cache/
.uv-cache/
.cache/
.cache*/
.github/
.pdm-build/
.pdm-build/**
.pdm-python
.eggs/
*.egg-info/
.git/
!.git/
.git/*
.vscode/
!.git/HEAD
!.git/packed-refs
!.git/refs/
!.git/refs/heads/
!.git/refs/heads/**
!.git/refs/heads/*
venv/
.venv/
.venv-old/
.docker_venv/
.docker-venv/
node_modules/
abx-dl/
abxpkg/
abx-plugins/
abxbus/
chrome/
chromeprofile/
chrome_profile/
lib/
out/
tests/out/
users/
archive/
crawls/
snapshots/
logs/
archivebox-docker-smoke*/
archivebox-compose-smoke*/
docker-test/
docker-test*/
core
*.core
pdm.dev.lock
pdm.lock
@@ -68,37 +30,13 @@ dist/
brew_dist/
deb_dist/
pip_dist/
cache/
caches/
assets/
docker/
website/
typings/
# The runtime COPY needs ArchiveBox's package and bin/ entrypoint, not the
# development UI screenshots or test trees. PNGs are already compressed, so
# including them creates a large image layer that later cleanup cannot remove.
publicsite/
old/
test/
tests/
archivebox/tests/
tmp/
.tmp/
data/
data*/
-
personas/
sources/
output/
index.sqlite3
index.sqlite3-wal
queue.sqlite3
*.sqlite*
data.*
.archivebox_id
ArchiveBox.conf
*.stdout
*.stderr
*.log
+1 -1
View File
@@ -1,2 +1,2 @@
github: ["ArchiveBox", "pirate"]
custom: ["https://donate.archivebox.io", "https://swag.archivebox.io"]
custom: ["https://donate.archivebox.io", "https://paypal.me/NicholasSweeting"]
+13 -12
View File
@@ -27,10 +27,10 @@ body:
id: steps_to_reproduce
attributes:
label: Steps to reproduce
description: Please provide the exact steps you took to trigger the issue (including any shell commands run, URLs visited, buttons clicked, etc.).
description: Please include the exact steps you took to trigger the issue.
render: markdown
placeholder: |
1. Started ArchiveBox by running: `docker run -v $PWD:/data -p 8000:8000 archivebox/archivebox` in iTerm2
1. Started ArchiveBox by running: `docker run -v $PWD:/data -p 8000:8000 archivebox/archivebox:latest`
2. Went to the https://127.0.0.1:8000/add/ page in Google Chrome
3. Typed 'https://example.com/xyz' into the 'Add URL' input field
4. Clicked the 'Add+' button
@@ -69,13 +69,13 @@ body:
0.8.6
ArchiveBox v0.8.6rc0 COMMIT_HASH=721427a BUILD_TIME=2024-10-21 12:57:02 1729515422
IN_DOCKER=False IN_QEMU=False ARCH=arm64 OS=Darwin PLATFORM=macOS-15.1-arm64-arm-64bit PYTHON=Cpython (venv)
EUID=502:20 UID=502:20 FS_UID=502:20 FS_PERMS=644 FS_ATOMIC=True FS_REMOTE=False
EUID=502:20 UID=502:20 PUID=502:20 FS_UID=502:20 FS_PERMS=644 FS_ATOMIC=True FS_REMOTE=False
DEBUG=False IS_TTY=True SUDO=False ID=dfa11485:aa78ad45 SEARCH_BACKEND=ripgrep LDAP=False
Binary Dependencies:
√ python 3.14.0 venv_pip ~/.venv/bin/python
√ django 6.0 venv_pip ~/.venv/lib/python3.14/site-packages/django/__init__.py
√ sqlite 2.6.0 venv_pip ~/.venv/lib/python3.14/site-packages/django/db/backends/sqlite3/base.py
√ python 3.11.9 venv_pip ~/.venv/bin/python
√ django 5.1.2 venv_pip ~/.venv/lib/python3.11/site-packages/django/__init__.py
√ sqlite 2.6.0 venv_pip ~/.venv/lib/python3.11/site-packages/django/db/backends/sqlite3/base.py
√ pip 24.3.1 venv_pip ~/.venv/bin/pip
...
validations:
@@ -93,7 +93,7 @@ body:
- apt
- brew
- nix
- Docker (or Podman/LXC/K8s/TrueNAS/Proxmox/etc)
- Docker (or other container system like podman/LXC/Kubernetes or TrueNAS/Cloudron/YunoHost/etc.)
- Other
- type: dropdown
@@ -116,15 +116,15 @@ body:
id: filesystem
attributes:
label: What type of drive are you using to store your ArchiveBox data?
description: Are you using a [remote filesystem](https://github.com/ArchiveBox/ArchiveBox/wiki/Setting-Up-Storage#supported-remote-filesystems) or FUSE mount for `data/` OR `data/archive`?
description: Are you using a [remote filesystem](https://github.com/ArchiveBox/ArchiveBox/wiki/Setting-Up-Storage#supported-remote-filesystems) or FUSE mount for `data/` or `data/archive` (e.g. NFS/SMB/CIFS/etc. or FUSE/RClone/S3/B2/OneDrive/etc.)?
options:
- label: "some of `data/` is on a local SSD or NVMe drive"
- label: "`data/` is on a local SSD or NVMe drive"
required: false
- label: "some of `data/` is on a spinning hard drive or external USB drive"
- label: "`data/` is on a spinning hard drive or external USB drive"
required: false
- label: "some of `data/` is on a network mount (e.g. NFS/SMB/Ceph/GlusterFS/etc.)"
- label: "`data/` is on a network mount (e.g. NFS/SMB/CIFS/etc.)"
required: false
- label: "some of `data/` is on a FUSE mount (e.g. SSHFS/RClone/S3/B2/Google Drive/Dropbox/etc.)"
- label: "`data/` is on a FUSE mount (e.g. SSHFS/RClone/S3/B2/OneDrive, etc.)"
required: false
@@ -191,6 +191,7 @@ body:
In the meantime please consider:
- 💰 [Donating to support ArchiveBox open-source](https://github.com/ArchiveBox/ArchiveBox/wiki/Donations)
- 👨‍✈️ [Hiring us for corporate deployments](https://docs.monadical.com/s/archivebox-consulting-services) with professional support, custom feature development, and help with CAPTCHAs/rate-limits
- 🔍 [Searching the Documentation](https://docs.archivebox.io/) for answers to common questions
- 📚 Reading the [Troubleshooting Guide](https://github.com/ArchiveBox/ArchiveBox/wiki)
- ✨ Testing out a newer [`BETA` release](https://github.com/ArchiveBox/ArchiveBox/releases) (issues are often already fixed in our latest `BETA` releases)
+15 -12
View File
@@ -24,7 +24,7 @@ body:
attributes:
label: "What is the problem that your feature request solves?"
description: |
Describe the problem or need that your feature request solves, feel free to include any screenshots or examples.
Describe the problem that your feature request solves, feel free to include any screenshots or examples.
placeholder: |
e.g. I need to be able to archive spanish and french subtitle files from a particular movie site https://example.com/somevideos that's going down soon.
validations:
@@ -35,7 +35,7 @@ body:
attributes:
label: "What is your proposed solution?"
description: |
Describe the ideal specific solution you'd want, *and whether it fits into any broader scope of changes*.
Describe the ideal specific solution you'd want, and whether it fits into any broader scope of changes.
placeholder: |
e.g. I specifically need a new archive method to look for multilingual subtitle files related to pages.
The bigger picture solution is the ability for custom user scripts to be run in a puppeteer context during archiving.
@@ -47,7 +47,7 @@ body:
attributes:
label: "What hacks or alternative solutions have you tried to solve the problem?"
description: |
A description of any alternative approaches, workarounds, or other solutions you've considered to fix the problem.
A clear and concise description of any alternative solutions, workarounds, or other software you've considered using to fix the problem.
placeholder: |
e.g. I wait for archivebox to finish archiving the page, then I manually run `yt-dlp --subs <URL>` inside
the `data/archive/<timestamp>/` directory to download the subtitle files and add them to the snapshot folder.
@@ -57,11 +57,10 @@ body:
- type: textarea
id: version
attributes:
label: Share the entire output of the `archivebox version` command for the current version you are using.
label: What version of ArchiveBox are you currently using?
description: |
DO NOT JUST ENTER "the latest version" OR YOUR ISSUE WILL BE CLOSED.
We need to know what version of ArchiveBox and what feature flags you're currently running with in order to contextualize your feature request.
Sometimes we've already fixed the issues in newer BETA versions, sometimes features already exist but may not be available in your specific environment.
We need to know what version of ArchiveBox and what OS you're currently using in order to contextualize your feature request.
Sometimes we've already fixed the issues in newer BETA versions, sometimes features already exist but may not be available in specific environments/versions.
Run the `archivebox version` command inside your current collection dir and paste the *full output* here (*not just the version number*).
For Docker Compose run: `docker compose run archivebox version`
@@ -71,13 +70,13 @@ body:
0.8.6
ArchiveBox v0.8.6rc0 COMMIT_HASH=721427a BUILD_TIME=2024-10-21 12:57:02 1729515422
IN_DOCKER=False IN_QEMU=False ARCH=arm64 OS=Darwin PLATFORM=macOS-15.1-arm64-arm-64bit PYTHON=Cpython (venv)
EUID=502:20 UID=502:20 FS_UID=502:20 FS_PERMS=644 FS_ATOMIC=True FS_REMOTE=False
EUID=502:20 UID=502:20 PUID=502:20 FS_UID=502:20 FS_PERMS=644 FS_ATOMIC=True FS_REMOTE=False
DEBUG=False IS_TTY=True SUDO=False ID=dfa11485:aa78ad45 SEARCH_BACKEND=ripgrep LDAP=False
Binary Dependencies:
√ python 3.14.0 venv_pip ~/.venv/bin/python
√ django 6.0 venv_pip ~/.venv/lib/python3.14/site-packages/django/__init__.py
√ sqlite 2.6.0 venv_pip ~/.venv/lib/python3.14/site-packages/django/db/backends/sqlite3/base.py
√ python 3.11.9 venv_pip ~/.venv/bin/python
√ django 5.1.2 venv_pip ~/.venv/lib/python3.11/site-packages/django/__init__.py
√ sqlite 2.6.0 venv_pip ~/.venv/lib/python3.11/site-packages/django/db/backends/sqlite3/base.py
√ pip 24.3.1 venv_pip ~/.venv/bin/pip
...
validations:
@@ -94,7 +93,7 @@ body:
required: false
- label: "It would be nice to have eventually"
required: false
- label: "I'm willing to [start a PR](https://github.com/ArchiveBox/ArchiveBox#archivebox-development) to develop this myself"
- label: "I'm willing to [work on a PR](https://github.com/ArchiveBox/ArchiveBox#archivebox-development) to develop this myself"
required: false
- label: "I have [donated money](https://github.com/ArchiveBox/ArchiveBox/wiki/Donations) to go towards fixing this issue"
required: false
@@ -109,6 +108,9 @@ body:
required: false
- label: "I've had a lot of difficulty getting ArchiveBox set up"
required: false
- label: "I would pay $10/mo for a hosted version of ArchiveBox if it had this feature"
required: false
- type: markdown
attributes:
value: |
@@ -120,5 +122,6 @@ body:
In the meantime please consider:
- 💰 [Donating to support ArchiveBox open-source](https://github.com/ArchiveBox/ArchiveBox/wiki/Donations)
- 📊 [Hiring us for corporate deployments](https://docs.monadical.com/s/archivebox-consulting-services) with professional support, custom feature development, and help with CAPTCHAs/rate-limits
- 🔍 [Searching the Documentation](https://docs.archivebox.io/) for answers to common questions
- ✨ Testing out a newer [`BETA` release](https://github.com/ArchiveBox/ArchiveBox/releases) (issues are often already fixed in our latest `BETA` releases)
@@ -47,5 +47,6 @@ body:
In the meantime please consider:
- 💰 [Donating to support ArchiveBox open-source](https://github.com/ArchiveBox/ArchiveBox/wiki/Donations)
- 👨‍✈️ [Hiring us for corporate deployments](https://docs.monadical.com/s/archivebox-consulting-services) with professional support, custom feature development, and help with CAPTCHAs/rate-limits
- 🔍 [Checking out the new ReadTheDocs Documentation](https://docs.archivebox.io/)
- ✨ Helping us test a newer [`BETA` release](https://github.com/ArchiveBox/ArchiveBox/releases)
+4 -4
View File
@@ -1,8 +1,8 @@
blank_issues_enabled: false
contact_links:
- name: ❓ Ask a question or start a discussion
url: https://github.com/ArchiveBox/ArchiveBox/discussions
about: "Ask a question, get support, or start a design discussion (to report a problem please use '🐞 Bug report' instead)"
- name: 💬 Chat with the dev team & community on Zulip
- name: 💬 Chat with us on Zulip
url: https://zulip.archivebox.io
about: "Join us on our Zulip forum to chat with the developers and other users (it's similar to Discord but self-hosted)."
- name: 💁‍♂️ Hire us to provide archiving for your organization
url: https://docs.monadical.com/s/archivebox-consulting-services
about: "We provide hosting, develoment, and support, including on-prem/cloud setup w/ SSO & storage, CAPTCHA/rate-limiting avoidance, etc."
+15 -49
View File
@@ -2,15 +2,18 @@
---
## Security Documentation
## Security Information
Please start by reading these links to understand our existing security model, known issues, API surfaces, and setup steps:
Please see this wiki page for important notices about ArchiveBox security, publishing your archives securely, and the dangers of executing archived JS:
https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview
Also see this section of the README about important caveats when running ArchiveBox:
https://github.com/ArchiveBox/ArchiveBox?tab=readme-ov-file#caveats
You can also read these pages for more information about ArchiveBox's internals, development environment, DB schema, and more:
- https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview
- https://github.com/ArchiveBox/ArchiveBox/security/advisories/
- https://github.com/ArchiveBox/ArchiveBox/tree/v0.7.4#security-risks-of-viewing-archived-js
- https://github.com/ArchiveBox/ArchiveBox/wiki/Publishing-Your-Archive#security-concerns
- https://github.com/ArchiveBox/ArchiveBox/wiki/Setting-up-Authentication
- https://github.com/ArchiveBox/ArchiveBox#archive-layout
- https://github.com/ArchiveBox/ArchiveBox#archivebox-development
- https://github.com/ArchiveBox/ArchiveBox/wiki/Upgrading-or-Merging-Archives
@@ -20,49 +23,12 @@ Please start by reading these links to understand our existing security model, k
## Reporting a Vulnerability
~We use Github's built-in [Private Reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) feature to accept vulnerability reports.~ We no longer offer CVEs or accept private reporting, 95% of the reports we received were low quality AI slop, so now we just ask for normal Github Issues or PRs.
We use Github's built-in [Private Reporting](https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing-information-about-vulnerabilities/privately-reporting-a-security-vulnerability) feature to accept vulnerability reports.
You can contact the maintainers via our public [Zulip Chat Server zulip.archivebox.io](https://zulip.archivebox.io) or [Twitter DMs @ArchiveBoxApp](https://twitter.com/ArchiveBoxApp) if you have a truly urgent security report, but we do not issue public CVEs.
1. Go to the Security tab on our Github repo: https://github.com/ArchiveBox/ArchiveBox/security
---
2. Click the ["Report a Vulnerability"](https://github.com/ArchiveBox/ArchiveBox/security/advisories/new) button
## CVEs and Report Credits
3. Fill out the form to submit the details of the report and it will be securely sent to the maintainers
> [!IMPORTANT]
> **Reports that only affect `dev` or beta versions will be converted to normal Github issues and will not be issued CVEs.**
> Reporters **will still receive credit in release notes** however.
> [!TIP]
> ~Reports that affect **stable published versions will be issued CVEs** and published w/ full credit.~
Please read our existing published [security advisories](https://github.com/ArchiveBox/ArchiveBox/security/advisories), [Security Overview Docs](https://github.com/ArchiveBox/ArchiveBox/wiki/Security-Overview), and [issues](https://github.com/ArchiveBox/ArchiveBox/issues) and avoid creating duplicates.
---
## Threat Model
### All admin users are assumed to be trusted
ArchiveBox does not support granular user permissions, there are only two types of users available in the Admin UI & REST API:
- logged in admin superusers (which are assumed to have `root` on the ArchiveBox host machine already)
- non-logged in anonymous guest users
Because admin / REST API / CLI / Python API users are already assumed to have `root` on the host machine, there are deliberately no protections against admins inputting malicious config values, attempts to attack other admin users, or destructive/malicious actions. Permissions for non-logged in users are managed via the [`PERMISSIONS`](https://github.com/archiveBox/archiveBox/wiki/configuration#permissions) + [`PUBLIC_INDEX` + `PUBLIC_ADD_VIEW`](https://github.com/archiveBox/archiveBox/wiki/configuration#public_index--public_add_view) + other config options.
### Sanitization of Config Values, Filesystem Paths, DB Fields, etc.
Non-logged in users can only choose between public Persona config presets on the `/add/` page, but they should never be allowed to directly set config options like `*_ARGS`, `*_BINARY` or DB fields like `url`, `title`, `tag.name`, `notes` without sanitization/filtering on save and/or use.
Non-logged in users should be tightly limited to prevent attacks like SQL/shell injection, path traversal attacks, XSS, cookie leaks between admin/non-admin domains, etc. Any reports related to non-logged-in users will be prioritized as this is a surface we care a lot about hardening.
### XSS, CSRF, CORS, CSP
`< v0.9.x` versions do not include any XSS/CSRF protections at all, as documented in the [readme](https://github.com/ArchiveBox/ArchiveBox/tree/v0.7.4#security-risks-of-viewing-archived-js), [wiki](https://github.com/ArchiveBox/ArchiveBox/wiki/Publishing-Your-Archive#security-concerns), [issue #239](https://github.com/ArchiveBox/ArchiveBox/issues/239), and [existing advisory](https://github.com/ArchiveBox/ArchiveBox/security/advisories/GHSA-cr45-98w9-gwqx). Do not open advisories related to XSS or CSRF for those versions, as they are missing fundamental architectural changes added in `v0.9.0` needed to support per-snapshot replay isolation.
[`SERVER_SECURITY_MODE`](https://github.com/archiveBox/archiveBox/wiki/configuration#server_security_mode) + CSRF/XSS defenses were only added in the new 0.9.0 `dev` work and are planned to land in `main` in mid-2026.
### SSRF / URL Validation
Note archivebox is **designed to be able to archive any URL the server can reach**, including internal intranet URLs, private IPs, and localhost. Users are expected to configure `URL_ALLOWLIST`/`URL_DENYLIST` or proper firewall/ACL-level blocking at the network level if they need to limit what the server should see.
It does a basic check to try and avoid recursively archiving snapshots hosted by the archivebox server itself, but it's not a strict security check, it's just to prevent wasted CPU/disk from recursively archiving snapshots in a browser that has the ArchiveBox Browser Extension turned on.
You can also contact the maintainers via our public [Zulip Chat Server zulip.archivebox.io](https://zulip.archivebox.io) or [Twitter DMs @ArchiveBoxApp](https://twitter.com/ArchiveBoxApp).
-81
View File
@@ -1,81 +0,0 @@
{
"properties": {
"CC_BINARY": {
"default": "cc"
},
"LDAPSEARCH_BINARY": {
"default": "ldapsearch"
},
"INITDB_BINARY": {
"default": "initdb"
},
"PG_CTL_BINARY": {
"default": "pg_ctl"
}
},
"required_binaries": [
{
"name": "{CC_BINARY}",
"binproviders": "env,apt",
"overrides": {
"apt": {
"install_args": [
"build-essential"
]
}
}
},
{
"name": "{LDAPSEARCH_BINARY}",
"binproviders": "env,apt",
"overrides": {
"env": {
"version": [
"ldapsearch",
"-VV"
]
},
"apt": {
"install_args": [
"ldap-utils",
"python3-dev",
"python3-setuptools",
"libssl-dev",
"libldap2-dev",
"libsasl2-dev",
"zlib1g-dev",
"libatomic1"
],
"version": [
"ldapsearch",
"-VV"
]
}
}
}
],
"postgres_binaries": [
{
"name": "{INITDB_BINARY}",
"binproviders": "env,apt",
"overrides": {
"apt": {
"install_args": [
"postgresql"
]
}
}
},
{
"name": "{PG_CTL_BINARY}",
"binproviders": "env,apt",
"overrides": {
"apt": {
"install_args": [
"postgresql"
]
}
}
}
]
}
-13
View File
@@ -1,13 +0,0 @@
{
"properties": {
"BREW_BINARY": {
"default": "brew"
}
},
"required_binaries": [
{
"name": "{BREW_BINARY}",
"binproviders": "env"
}
]
}
-44
View File
@@ -1,44 +0,0 @@
{
"properties": {
"CLANG_BINARY": {
"default": "clang"
},
"LDAPVC_BINARY": {
"default": "ldapvc"
}
},
"required_binaries": [
{
"name": "{CLANG_BINARY}",
"binproviders": "env,brew",
"overrides": {
"brew": {
"install_args": [
"llvm"
]
}
}
},
{
"name": "{LDAPVC_BINARY}",
"binproviders": "env,brew",
"overrides": {
"env": {
"version": [
"ldapvc",
"-VV"
]
},
"brew": {
"install_args": [
"openldap"
],
"version": [
"ldapvc",
"-VV"
]
}
}
}
]
}
@@ -1,21 +0,0 @@
{
"properties": {
"LDAPVC_BINARY": {
"default": "ldapvc"
}
},
"required_binaries": [
{
"name": "{LDAPVC_BINARY}",
"binproviders": "env",
"overrides": {
"env": {
"version": [
"ldapvc",
"-VV"
]
}
}
}
]
}
-345
View File
@@ -1,345 +0,0 @@
{
"properties": {
"CURL_BINARY": {
"default": "curl"
},
"BASH_BINARY": {
"default": "bash"
},
"CHMOD_BINARY": {
"default": "chmod"
},
"DOCKER_BINARY": {
"default": "docker"
},
"GIT_BINARY": {
"default": "git"
},
"GREP_BINARY": {
"default": "grep"
},
"GH_BINARY": {
"default": "gh"
},
"EXPORTFS_BINARY": {
"default": "exportfs"
},
"JQ_BINARY": {
"default": "jq"
},
"MKDIR_BINARY": {
"default": "mkdir"
},
"MOUNT_CIFS_BINARY": {
"default": "mount.cifs"
},
"MOUNT_NFS_BINARY": {
"default": "mount.nfs"
},
"NODE_BINARY": {
"default": "node"
},
"PS_BINARY": {
"default": "ps"
},
"PYTHON_BINARY": {
"default": "python"
},
"PV_BINARY": {
"default": "pv"
},
"SSH_BINARY": {
"default": "ssh"
},
"SED_BINARY": {
"default": "sed"
},
"SMBD_BINARY": {
"default": "smbd"
},
"SUDO_BINARY": {
"default": "sudo"
},
"SYSTEMCTL_BINARY": {
"default": "systemctl"
},
"TAR_BINARY": {
"default": "tar"
},
"TREE_BINARY": {
"default": "tree"
},
"TR_BINARY": {
"default": "tr"
},
"RM_BINARY": {
"default": "rm"
},
"UMOUNT_BINARY": {
"default": "umount"
},
"UV_BINARY": {
"default": "uv"
},
"UNAME_BINARY": {
"default": "uname"
}
},
"ci_binaries": [
{
"name": "{BASH_BINARY}",
"binproviders": "env,brew,apt"
},
{
"name": "{PYTHON_BINARY}",
"binproviders": "env"
},
{
"name": "{JQ_BINARY}",
"binproviders": "env,brew,apt"
},
{
"name": "{UV_BINARY}",
"binproviders": "env"
}
],
"bsd_binaries": [
{
"name": "{BASH_BINARY}",
"binproviders": "env,pyinfra"
}
],
"freebsd_binaries": [
{
"name": "{SUDO_BINARY}",
"binproviders": "env,pyinfra"
}
],
"docker_binaries": [
{
"name": "{DOCKER_BINARY}",
"binproviders": "env"
},
{
"name": "{GIT_BINARY}",
"binproviders": "env,brew,apt"
},
{
"name": "{PYTHON_BINARY}",
"binproviders": "env"
},
{
"name": "{UNAME_BINARY}",
"binproviders": "env"
}
],
"deploy_binaries": [
{
"name": "{GIT_BINARY}",
"binproviders": "env,brew,apt"
},
{
"name": "{PYTHON_BINARY}",
"binproviders": "env"
},
{
"name": "{SSH_BINARY}",
"binproviders": "env"
}
],
"docker_debug_binaries": [
{
"name": "{DOCKER_BINARY}",
"binproviders": "env"
},
{
"name": "{PV_BINARY}",
"binproviders": "env,brew,apt"
},
{
"name": "{TAR_BINARY}",
"binproviders": "env,brew,apt"
},
{
"name": "{TREE_BINARY}",
"binproviders": "env,brew,apt"
}
],
"docker_validation_binaries": [
{
"name": "{DOCKER_BINARY}",
"binproviders": "env"
},
{
"name": "{SUDO_BINARY}",
"binproviders": "env"
},
{
"name": "{GREP_BINARY}",
"binproviders": "env"
},
{
"name": "{MKDIR_BINARY}",
"binproviders": "env"
},
{
"name": "{RM_BINARY}",
"binproviders": "env"
},
{
"name": "{SED_BINARY}",
"binproviders": "env"
},
{
"name": "{SSH_BINARY}",
"binproviders": "env"
},
{
"name": "{TR_BINARY}",
"binproviders": "env"
},
{
"name": "{UNAME_BINARY}",
"binproviders": "env"
}
],
"mount_validation_binaries": [
{
"name": "{BASH_BINARY}",
"binproviders": "env"
},
{
"name": "{CHMOD_BINARY}",
"binproviders": "env"
},
{
"name": "{EXPORTFS_BINARY}",
"binproviders": "env,apt",
"overrides": {
"apt": {
"install_args": [
"nfs-kernel-server"
]
}
}
},
{
"name": "{MKDIR_BINARY}",
"binproviders": "env"
},
{
"name": "{MOUNT_CIFS_BINARY}",
"binproviders": "env,apt",
"overrides": {
"apt": {
"install_args": [
"cifs-utils"
]
}
}
},
{
"name": "{MOUNT_NFS_BINARY}",
"binproviders": "env,apt",
"overrides": {
"apt": {
"install_args": [
"nfs-common"
]
}
}
},
{
"name": "{SMBD_BINARY}",
"binproviders": "env,apt",
"overrides": {
"apt": {
"install_args": [
"samba"
]
}
}
},
{
"name": "{SUDO_BINARY}",
"binproviders": "env"
},
{
"name": "{SYSTEMCTL_BINARY}",
"binproviders": "env"
},
{
"name": "{UMOUNT_BINARY}",
"binproviders": "env"
}
],
"process_binaries": [
{
"name": "{PS_BINARY}",
"binproviders": "env"
}
],
"docs_binaries": [
{
"name": "{CURL_BINARY}",
"binproviders": "env,brew,apt"
},
{
"name": "{SUDO_BINARY}",
"binproviders": "env"
}
],
"git_binaries": [
{
"name": "{GIT_BINARY}",
"binproviders": "env,brew,apt"
}
],
"github_binaries": [
{
"name": "{GH_BINARY}",
"binproviders": "env,brew,apt"
}
],
"node_binaries": [
{
"name": "{NODE_BINARY}",
"binproviders": "env,npm",
"min_version": "22.23.1",
"overrides": {
"npm": {
"install_args": [
"node@22.23.1"
],
"postinstall_scripts": true
}
}
}
],
"release_binaries": [
{
"name": "{UV_BINARY}",
"binproviders": "env,pip,brew"
},
{
"name": "{PYTHON_BINARY}",
"binproviders": "env"
},
{
"name": "{GH_BINARY}",
"binproviders": "env,brew,apt"
},
{
"name": "{GIT_BINARY}",
"binproviders": "env,brew,apt"
},
{
"name": "{JQ_BINARY}",
"binproviders": "env,brew,apt"
},
{
"name": "{CURL_BINARY}",
"binproviders": "env,brew,apt"
}
]
}
-39
View File
@@ -1,39 +0,0 @@
#!/usr/bin/env python3
"""Discover every ArchiveBox test file and expose it as one CI job."""
import json
from pathlib import Path
def main() -> None:
root = Path.cwd().resolve()
archivebox_tests = sorted((root / "archivebox/tests").glob("test_*.py"))
if not archivebox_tests:
raise SystemExit("No ArchiveBox tests discovered")
matrix: list[dict[str, object]] = []
for path in archivebox_tests:
test_path = path.relative_to(root).as_posix()
matrix.append(
{
"name": f"main/{path.stem}",
"paths": [test_path],
"paths_arg": test_path,
"extra": "ldap" if test_path.endswith("/test_auth_ldap.py") else "",
"count": 1,
},
)
discovered_paths = [path for entry in matrix for path in entry["paths"]]
if len(discovered_paths) != len(set(discovered_paths)):
raise SystemExit("Tests were not discovered exactly once")
if sorted(discovered_paths) != [path.relative_to(root).as_posix() for path in archivebox_tests]:
raise SystemExit("Discovered test shard coverage does not match test files")
print(f"Discovered {len(discovered_paths)} test files exactly once across {len(matrix)} jobs")
print(json.dumps(matrix, separators=(",", ":")))
if __name__ == "__main__":
main()
-92
View File
@@ -1,92 +0,0 @@
name: CI
on:
pull_request:
branches: [dev, main]
push:
branches: [dev, main]
workflow_dispatch:
permissions:
actions: read
contents: read
packages: write
security-events: write
concurrency:
group: ci-${{ github.event_name == 'pull_request' && format('pr-{0}', github.event.pull_request.number) || github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || github.ref }}
cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }}
jobs:
lint:
name: Linters
uses: ./.github/workflows/lint.yml
secrets: inherit
install-cli-platform:
name: Install and CLI platform compatibility
uses: ./.github/workflows/test.yml
secrets: inherit
tests:
name: Discovered test matrix
uses: ./.github/workflows/test-parallel.yml
secrets: inherit
documentation:
name: Documentation and root tests
uses: ./.github/workflows/docs.yml
secrets: inherit
codeql:
name: CodeQL
uses: ./.github/workflows/codeql.yml
secrets: inherit
python-artifacts:
name: Tested Python artifacts
uses: ./.github/workflows/pip.yml
secrets: inherit
docker-digests:
name: Tested Docker images
uses: ./.github/workflows/docker.yml
with:
push_digests: false
secrets: inherit
required:
name: All required CI lanes
if: ${{ always() }}
needs:
- lint
- install-cli-platform
- tests
- documentation
- codeql
- python-artifacts
- docker-digests
runs-on: ubuntu-24.04
steps:
- name: Verify every required lane succeeded
env:
LINT_RESULT: ${{ needs.lint.result }}
INSTALL_CLI_PLATFORM_RESULT: ${{ needs.install-cli-platform.result }}
TESTS_RESULT: ${{ needs.tests.result }}
DOCUMENTATION_RESULT: ${{ needs.documentation.result }}
CODEQL_RESULT: ${{ needs.codeql.result }}
PYTHON_ARTIFACTS_RESULT: ${{ needs.python-artifacts.result }}
DOCKER_DIGESTS_RESULT: ${{ needs.docker-digests.result }}
run: |
for result in \
"$LINT_RESULT" \
"$INSTALL_CLI_PLATFORM_RESULT" \
"$TESTS_RESULT" \
"$DOCUMENTATION_RESULT" \
"$CODEQL_RESULT" \
"$PYTHON_ARTIFACTS_RESULT" \
"$DOCKER_DIGESTS_RESULT"
do
[[ "$result" == success ]]
done
echo 'All required CI lanes and tested release artifacts succeeded.'
-40
View File
@@ -1,40 +0,0 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
issues: write
id-token: write
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@b76a0776ae74036e77cd11018083743453d7ad35 # v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
additional_permissions: |
actions: read
claude_args: '--allowed-tools Bash(gh pr:*)'
+61 -11
View File
@@ -1,21 +1,42 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL"
on:
workflow_call:
push:
branches: [ "dev" ]
pull_request:
branches: [ "dev" ]
schedule:
- cron: '33 17 * * 6'
concurrency:
group: codeql-${{ github.repository }}-${{ github.workflow }}-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-24.04
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
@@ -25,18 +46,47 @@ jobs:
include:
- language: python
build-mode: none
- language: javascript-typescript
build-mode: none
# CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
uses: actions/checkout@v4
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@b7351df727350dca84cb9d725d57dcf5bc82ba26 # v3
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- if: matrix.build-mode == 'manual'
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@b7351df727350dca84cb9d725d57dcf5bc82ba26 # v3
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{matrix.language}}"
+64
View File
@@ -0,0 +1,64 @@
name: Build Debian package
on:
workflow_dispatch:
push:
env:
DEB_BUILD_OPTIONS: nocheck
jobs:
build:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v3
with:
submodules: true
fetch-depth: 1
- name: Install packaging dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y \
python3 python3-dev python3-pip python3-venv python3-all \
dh-python debhelper devscripts dput software-properties-common \
python3-distutils python3-setuptools python3-wheel python3-stdeb
# - name: Build Debian/Apt sdist_dsc
# run: |
# ./bin/build_pip.sh
# - name: Check ArchiveBox version
# run: |
# # must create dir needed for snaps to run as non-root on github actions
# sudo mkdir -p /run/user/1001 && sudo chmod -R 777 /run/user/1001
# mkdir "${{ github.workspace }}/data" && cd "${{ github.workspace }}/data"
# archivebox --version
# archivebox init --setup
# - name: Add some links to test
# run: |
# cd "${{ github.workspace }}/data"
# archivebox add 'https://example.com'
# archivebox status
# - name: Commit built package
# run: |
# cd deb_dist/
# git config --local user.email "action@github.com"
# git config --local user.name "GitHub Action"
# git commit -m "Debian package autobuild" -a
# - name: Push build to Github
# uses: ad-m/github-push-action@master
# with:
# github_token: ${{ secrets.GITHUB_TOKEN }}
# repository: ArchiveBox/debian-archivebox
# branch: ${{ github.ref }}
# directory: deb_dist
# - name: Push build to Launchpad PPA
# run: |
# debsign -k "$PGP_KEY_ID" "deb_dist/archivebox_${VERSION}-${DEBIAN_VERSION}_source.changes"
# dput archivebox "deb_dist/archivebox_${VERSION}-${DEBIAN_VERSION}_source.changes"
-50
View File
@@ -1,50 +0,0 @@
name: Deploy Publicsite to GitHub Pages
on:
push:
branches: [dev]
pull_request:
workflow_dispatch:
workflow_run:
workflows: ['Capture publicsite screenshots']
types: [completed]
branches: [dev]
permissions:
contents: read
actions: read
concurrency:
# Failed captures must not displace a pending push deployment.
group: fast-pages-${{ github.event_name == 'workflow_run' && github.event.workflow_run.conclusion != 'success' && github.run_id || github.ref }}
cancel-in-progress: false
jobs:
build:
if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event_name == 'pull_request' && github.ref || 'dev' }}
- uses: astral-sh/setup-uv@v7
- name: Restore latest complete screenshots
run: uv run --no-project python bin/restore_site_screenshots.py archivebox publicsite/screenshots
env:
GH_TOKEN: ${{ github.token }}
- name: Render current gallery layout without recapturing images
run: uv run --no-project python bin/generate_ui_screenshot_gallery.py refresh publicsite/screenshots/index.html
- uses: actions/upload-pages-artifact@v3
with:
path: publicsite
deploy:
if: github.event_name != 'pull_request'
needs: build
runs-on: ubuntu-latest
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- uses: actions/configure-pages@v5
- uses: actions/deploy-pages@v4
id: deployment
+66 -473
View File
@@ -1,115 +1,37 @@
name: Build Docker image
on:
workflow_call:
inputs:
push_digests:
description: Push validated per-platform images by digest for the release workflow
required: false
default: false
type: boolean
full_tests:
description: Run storage, CLI, and size validation in addition to release smoke checks
required: false
default: true
type: boolean
workflow_dispatch:
push:
branches:
- '**'
tags:
- 'v*'
# pull_request:
env:
DOCKERHUB_IMAGE: archivebox/archivebox
GHCR_IMAGE: ghcr.io/archivebox/archivebox
permissions:
actions: read
contents: read
packages: write
DOCKER_IMAGE: archivebox-ci
jobs:
build:
name: build ${{ matrix.platform }}
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-24.04
artifact_name: digest-linux-amd64
local_tag: archivebox-ci:amd64
- platform: linux/arm64
runner: ubuntu-24.04-arm
artifact_name: digest-linux-arm64
local_tag: archivebox-ci:arm64
buildx:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/checkout@v4
with:
python-version: "3.13"
submodules: true
fetch-depth: 1
- name: Install uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
enable-cache: false
cache-dependency-glob: uv.lock
- name: Resolve Docker through abxpkg
env:
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
shell: bash
run: |
set -Eeuo pipefail
ABXPKG_SPEC="$(uv run --no-cache --no-project python - <<'PY'
import tomllib
from pathlib import Path
packages = tomllib.loads(Path("uv.lock").read_text())["package"]
matches = [package for package in packages if package["name"] == "abxpkg"]
if len(matches) != 1:
raise SystemExit(f"Expected one locked abxpkg package, found {len(matches)}")
wheel = matches[0]["wheels"][0]
print("abxpkg @ {}#{}".format(wheel["url"], wheel["hash"].replace(":", "=")))
PY
)"
test -n "$ABXPKG_SPEC"
mkdir -p "$ABXPKG_LIB_DIR/env/bin"
export PATH="$ABXPKG_LIB_DIR/env/bin:$PATH"
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR" >> "$GITHUB_ENV"
echo "ABXPKG_SPEC=$ABXPKG_SPEC" >> "$GITHUB_ENV"
echo "$ABXPKG_LIB_DIR/env/bin" >> "$GITHUB_PATH"
docker_env="$(
uv run --no-cache --no-project --with "$ABXPKG_SPEC" abxpkg env \
--install \
--json \
--lib="$ABXPKG_LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:release_binaries" \
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries"
)"
JQ_BINARY="$ABXPKG_LIB_DIR/env/bin/jq"
"$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \
<<< "$docker_env" >> "$GITHUB_ENV"
docker_binary="$ABXPKG_LIB_DIR/env/bin/docker"
UV_BINARY="$ABXPKG_LIB_DIR/env/bin/uv"
{
echo "DOCKER_BINARY=$docker_binary"
echo "JQ_BINARY=$JQ_BINARY"
echo "UV_BINARY=$UV_BINARY"
} >> "$GITHUB_ENV"
for binary in "$docker_binary" "$JQ_BINARY" "$UV_BINARY"; do
test -L "$binary"
test -x "$binary"
done
"$docker_binary" --version
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
uses: docker/setup-buildx-action@v3
with:
version: latest
install: true
platforms: linux/amd64,linux/arm64
- name: Builder instance name
run: echo ${{ steps.buildx.outputs.name }}
@@ -117,398 +39,69 @@ jobs:
- name: Available platforms
run: echo ${{ steps.buildx.outputs.platforms }}
- name: Verify published abx-dl image
id: abx_dl_image
shell: bash
run: |
set -Eeuo pipefail
ABX_DL_VERSION="$("$UV_BINARY" run --no-cache --no-project python - <<'PY'
import tomllib
from pathlib import Path
- name: Cache Docker layers
uses: actions/cache@v3
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildx-
packages = tomllib.loads(Path("uv.lock").read_text())["package"]
matches = [package["version"] for package in packages if package["name"] == "abx-dl"]
if len(matches) != 1:
raise SystemExit(f"Expected one locked abx-dl package, found {len(matches)}")
print(matches[0])
PY
)"
test -n "$ABX_DL_VERSION"
ABX_DL_REFERENCE="${ABX_DL_IMAGE:-archivebox/abx-dl:${ABX_DL_VERSION}}"
INSPECT_OUTPUT="$("$DOCKER_BINARY" buildx imagetools inspect "$ABX_DL_REFERENCE")"
printf '%s\n' "$INSPECT_OUTPUT"
ABX_DL_DIGEST="$(INSPECT_OUTPUT="$INSPECT_OUTPUT" "$UV_BINARY" run --no-cache --no-project python - <<'PY'
import os
digest = next(
(fields[1] for line in os.environ["INSPECT_OUTPUT"].splitlines() if (fields := line.split()) and fields[0] == "Digest:"),
"",
)
print(digest)
PY
)"
[[ "$ABX_DL_DIGEST" == sha256:* ]]
ABX_DL_IMAGE="${ABX_DL_REFERENCE%%@*}@${ABX_DL_DIGEST}"
echo "image=${ABX_DL_IMAGE}" >> "$GITHUB_OUTPUT"
- name: Login to Docker Hub
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
if: inputs.push_digests
- name: Docker Login
uses: docker/login-action@v3
if: github.event_name != 'pull_request'
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
if: inputs.push_digests
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Collect Docker labels
- name: Collect Docker tags
# https://github.com/docker/metadata-action
id: docker_meta
shell: bash
run: |
set -Eeuo pipefail
VERSION="$("$UV_BINARY" run --no-cache --no-project python - <<'PY'
import tomllib
from pathlib import Path
uses: docker/metadata-action@v5
with:
images: archivebox/archivebox,nikisweeting/archivebox
tags: |
# :stable
type=ref,event=branch
# :0.7.3
type=semver,pattern={{version}}
# :0.7
type=semver,pattern={{major}}.{{minor}}
# :sha-463ea54
type=sha
# :latest
type=raw,value=latest,enable=${{ github.ref == format('refs/heads/{0}', 'stable') }}
print(tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"])
PY
)"
test -n "$VERSION"
{
echo 'labels<<EOF'
echo "org.opencontainers.image.version=${VERSION}"
echo "org.opencontainers.image.revision=${GITHUB_SHA}"
echo 'org.opencontainers.image.source=https://github.com/ArchiveBox/ArchiveBox'
echo "io.archivebox.abx-dl.image=${{ steps.abx_dl_image.outputs.image }}"
echo 'EOF'
echo "version=${VERSION}"
} >> "$GITHUB_OUTPUT"
echo "[+] Building ${{ matrix.platform }} for ${VERSION} using ${{ steps.abx_dl_image.outputs.image }}"
- name: Build and push digest
- name: Build and push
id: docker_build
if: inputs.push_digests
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
uses: docker/build-push-action@v5
with:
context: ./
file: ./Dockerfile
builder: ${{ steps.buildx.outputs.name }}
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker_meta.outputs.tags }}
labels: ${{ steps.docker_meta.outputs.labels }}
build-args: |
ABX_DL_IMAGE=${{ steps.abx_dl_image.outputs.image }}
ARCHIVEBOX_COMMIT_HASH=${{ github.sha }}
pull: true
platforms: ${{ matrix.platform }}
# Keep the released image as the one cache authority. A second GHA
# cache can hold gzip variants of identical layers, and BuildKit may
# prefer those over :dev's zstd blobs. That counter-intuitively makes
# a cache hit larger than the released image. :dev advances only
# after both architectures pass the release checks.
cache-from: type=registry,ref=${{ env.DOCKERHUB_IMAGE }}:dev
# Inline metadata reuses the released compressed blobs directly;
# force-compression would recompress the whole ~740 MiB image. The
# compressed-size gate below remains the regression guard.
cache-to: |
${{ inputs.push_digests && 'type=inline' || '' }}
outputs: |
${{ inputs.full_tests && format('type=oci,dest={0}/archivebox-image.tar,compression=zstd,compression-level=3', runner.temp) || '' }}
${{ inputs.push_digests && format('type=image,"name={0},{1}",push-by-digest=true,name-canonical=true,push=true,compression=zstd,compression-level=3', env.DOCKERHUB_IMAGE, env.GHCR_IMAGE) || '' }}
- name: Build pull request image
id: docker_build_pr
if: ${{ !inputs.push_digests }}
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
context: ./
file: ./Dockerfile
builder: ${{ steps.buildx.outputs.name }}
labels: ${{ steps.docker_meta.outputs.labels }}
build-args: |
ABX_DL_IMAGE=${{ steps.abx_dl_image.outputs.image }}
ARCHIVEBOX_COMMIT_HASH=${{ github.sha }}
pull: true
platforms: ${{ matrix.platform }}
# Test the same layer lineage users receive. Reusing :dev avoids a
# second cache whose compression can drift from the released image.
cache-from: type=registry,ref=${{ env.DOCKERHUB_IMAGE }}:dev
outputs: |
type=docker,name=${{ matrix.local_tag }}
type=oci,dest=${{ runner.temp }}/archivebox-image.tar
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache-new
platforms: linux/amd64,linux/arm64
- name: Image digest
if: inputs.push_digests
run: echo ${{ steps.docker_build.outputs.digest }}
- name: Validate exact built image version and commit
if: ${{ !inputs.push_digests }}
shell: bash
run: |
set -Eeuo pipefail
IMAGE="${{ matrix.local_tag }}"
SHORT_SHA="${GITHUB_SHA::7}"
DATA_DIR="$(mktemp -d)"
"$DOCKER_BINARY" run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox init
ARCHIVEBOX_VERSION_OUTPUT="$("$DOCKER_BINARY" run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox version)"
printf '%s\n' "$ARCHIVEBOX_VERSION_OUTPUT"
"$DOCKER_BINARY" run --rm --platform '${{ matrix.platform }}' -v "$DATA_DIR":/data "$IMAGE" archivebox status
IMAGE_VERSION_OUTPUT="$("$DOCKER_BINARY" run --rm --platform '${{ matrix.platform }}' --entrypoint cat "$IMAGE" /VERSION.txt)"
ARCHIVEBOX_VERSION_OUTPUT="$ARCHIVEBOX_VERSION_OUTPUT" IMAGE_VERSION_OUTPUT="$IMAGE_VERSION_OUTPUT" \
"$UV_BINARY" run --no-cache --no-project python - \
"ArchiveBox v${{ steps.docker_meta.outputs.version }}" \
"COMMIT_HASH=${SHORT_SHA}" \
"COMMIT_HASH=${GITHUB_SHA}" <<'PY'
import os
import sys
version_output = os.environ["ARCHIVEBOX_VERSION_OUTPUT"]
image_version_lines = os.environ["IMAGE_VERSION_OUTPUT"].splitlines()
if sys.argv[1] not in version_output:
raise SystemExit(f"Missing version marker: {sys.argv[1]}")
if sys.argv[2] not in version_output:
raise SystemExit(f"Missing short commit marker: {sys.argv[2]}")
if sys.argv[3] not in image_version_lines:
raise SystemExit(f"Missing exact commit marker: {sys.argv[3]}")
PY
"$DOCKER_BINARY" run --rm --user 911:911 --platform '${{ matrix.platform }}' --entrypoint /venv/bin/python3 "$IMAGE" -c \
'import json, pathlib, stat; cache_file = "/opt/archivebox/lib/env/derived.env"; raw = open(cache_file).read().strip(); records = json.loads(raw[len("ABXPKG_DERIVED_CACHE=\x27"):-1]); projections = [projection for record in records.values() for projection in record.get("request_exec_projections", {}).values()]; plans = [projection["validation"] for projection in projections]; fingerprints = [fingerprint for plan in plans for fingerprint in plan["fingerprint"]]; actual = lambda fingerprint: (lambda path, value: {"path": str(path.resolve(strict=False)), "size": value.st_size, "mtime_ns": value.st_mtime_ns, "mode": stat.S_IMODE(value.st_mode), "euid": value.st_uid})(pathlib.Path(fingerprint["path"]), pathlib.Path(fingerprint["path"]).stat()); mismatches = [(fingerprint, actual(fingerprint)) for fingerprint in fingerprints if fingerprint != actual(fingerprint)]; assert projections and {plan.get("euid") for plan in plans} == {911}; assert all(not record.get("script_exec_plans") for record in records.values()); assert "ABXPKG_TMP_CACHE_DIR" not in json.dumps(projections); assert not mismatches, mismatches[:3]'
- name: Validate compressed candidate size
if: inputs.push_digests
shell: bash
run: |
set -Eeuo pipefail
DIGEST='${{ steps.docker_build.outputs.digest }}'
[[ "$DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]]
MANIFEST="$("$DOCKER_BINARY" buildx imagetools inspect --raw "${DOCKERHUB_IMAGE}@${DIGEST}")"
# Provenance wraps the real platform image and its attestation in an
# OCI index. Measure the image manifest, not the index/attestation.
IMAGE_DIGEST="$(MANIFEST="$MANIFEST" PLATFORM='${{ matrix.platform }}' "$UV_BINARY" run --no-cache --no-project python -c 'import json, os; manifest = json.loads(os.environ["MANIFEST"]); platform_os, platform_arch = os.environ["PLATFORM"].split("/")[:2]; print(next((item["digest"] for item in manifest.get("manifests", []) if item.get("platform", {}).get("os") == platform_os and item.get("platform", {}).get("architecture") == platform_arch), ""))')"
if [[ -n "$IMAGE_DIGEST" ]]; then
MANIFEST="$("$DOCKER_BINARY" buildx imagetools inspect --raw "${DOCKERHUB_IMAGE}@${IMAGE_DIGEST}")"
fi
COMPRESSED_SIZE="$(MANIFEST="$MANIFEST" "$UV_BINARY" run --no-cache --no-project python -c 'import json, os; manifest = json.loads(os.environ["MANIFEST"]); print(manifest["config"]["size"] + sum(layer["size"] for layer in manifest["layers"]))')"
LIMIT=$((750 * 1024 * 1024))
echo "${DIGEST} compressed_size=$((COMPRESSED_SIZE / 1024 / 1024)) MiB limit=750 MiB"
(( COMPRESSED_SIZE < LIMIT ))
- name: Resolve real Docker CLI test dependencies through abxpkg
if: inputs.full_tests && matrix.platform == 'linux/amd64'
shell: bash
run: |
set -Eeuo pipefail
mount_env="$(
uv run --no-cache --no-project --with "$ABXPKG_SPEC" abxpkg env \
--install \
--json \
--lib="$ABXPKG_LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docs_binaries" \
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:mount_validation_binaries"
)"
"$JQ_BINARY" -r 'to_entries[] | "\(.key)=\(.value)"' \
<<< "$mount_env" >> "$GITHUB_ENV"
for binary_name in bash chmod curl exportfs mkdir mount.cifs mount.nfs smbd sudo systemctl umount; do
binary="$ABXPKG_LIB_DIR/env/bin/$binary_name"
test -L "$binary"
test -x "$binary"
done
- name: Validate Docker UID and GID behavior on real NFS and SMB mounts
if: inputs.full_tests && matrix.platform == 'linux/amd64'
shell: bash
run: |
set -Eeuo pipefail
NFS_SOURCE="/srv/archivebox-ci-nfs"
SMB_SOURCE="/srv/archivebox-ci-smb"
NFS_MOUNT="$RUNNER_TEMP/archivebox-nfs-mount"
SMB_MOUNT="$RUNNER_TEMP/archivebox-smb-mount"
mounted_nfs=0
mounted_smb=0
cleanup() {
local original_status=$? cleanup_status=0
trap - EXIT
if [[ "$mounted_smb" == "1" ]] && ! "$SUDO_BINARY" "$UMOUNT_BINARY" "$SMB_MOUNT"; then
cleanup_status=1
fi
if [[ "$mounted_nfs" == "1" ]] && ! "$SUDO_BINARY" "$UMOUNT_BINARY" "$NFS_MOUNT"; then
cleanup_status=1
fi
if ! "$SUDO_BINARY" "$SYSTEMCTL_BINARY" stop smbd.service nfs-kernel-server.service; then
cleanup_status=1
fi
if [[ "$original_status" != "0" ]]; then
exit "$original_status"
fi
exit "$cleanup_status"
}
trap cleanup EXIT
"$SUDO_BINARY" "$MKDIR_BINARY" -p "$NFS_SOURCE" "$SMB_SOURCE" "$NFS_MOUNT" "$SMB_MOUNT"
"$SUDO_BINARY" "$MKDIR_BINARY" -p /etc/exports.d
"$SUDO_BINARY" "$CHMOD_BINARY" 0777 "$NFS_SOURCE" "$SMB_SOURCE" "$NFS_MOUNT" "$SMB_MOUNT"
"$SUDO_BINARY" "$BASH_BINARY" -c \
"printf '%s *(rw,sync,no_subtree_check,root_squash,insecure)\\n' '$NFS_SOURCE' > /etc/exports.d/archivebox-ci.exports"
"$SUDO_BINARY" "$BASH_BINARY" -c \
"printf '\\n[archivebox-ci]\\npath = %s\\nbrowseable = yes\\nread only = no\\nguest ok = yes\\nguest only = yes\\nforce user = nobody\\ncreate mask = 0777\\ndirectory mask = 0777\\n' '$SMB_SOURCE' >> /etc/samba/smb.conf"
"$SUDO_BINARY" "$SYSTEMCTL_BINARY" restart nfs-kernel-server.service
"$SUDO_BINARY" "$EXPORTFS_BINARY" -ra
"$SYSTEMCTL_BINARY" is-active --quiet nfs-kernel-server.service
"$SUDO_BINARY" "$SYSTEMCTL_BINARY" restart smbd.service
"$SYSTEMCTL_BINARY" is-active --quiet smbd.service
"$SUDO_BINARY" "$MOUNT_NFS_BINARY" -o vers=3,nolock "127.0.0.1:$NFS_SOURCE" "$NFS_MOUNT"
mounted_nfs=1
"$SUDO_BINARY" "$MOUNT_CIFS_BINARY" //127.0.0.1/archivebox-ci "$SMB_MOUNT" \
-o "guest,vers=3.0,uid=0,gid=0,forceuid,forcegid,file_mode=0777,dir_mode=0777,nounix"
mounted_smb=1
NFS_TEST_DIR="$NFS_MOUNT" \
SMB_TEST_DIR="$SMB_MOUNT" \
IMAGE="${{ matrix.local_tag }}" \
"$BASH_BINARY" bin/validate_docker_uid_gid.sh --local-only
- name: Validate Docker CLI add/list and compose behavior
if: inputs.full_tests && matrix.platform == 'linux/amd64'
shell: bash
env:
ARCHIVEBOX_IMAGE: ${{ matrix.local_tag }}
run: |
set -Eeuo pipefail
"$MKDIR_BINARY" "${{ github.workspace }}/data"
"$DOCKER_BINARY" run -v "${{ github.workspace }}/data":/data "$ARCHIVEBOX_IMAGE" init
"$SUDO_BINARY" "$BASH_BINARY" -c 'echo "127.0.0.1 www.test-nginx-1.local www.test-nginx-2.local" >> /etc/hosts'
"$DOCKER_BINARY" run --name www-nginx -p 80:80 -d nginx:1.29.5@sha256:0236ee02dcbce00b9bd83e0f5fbc51069e7e1161bd59d99885b3ae1734f3392e
"$DOCKER_BINARY" run -v "$PWD"/data:/data --network host "$ARCHIVEBOX_IMAGE" add http://www.test-nginx-1.local
echo "http://www.test-nginx-2.local" | "$DOCKER_BINARY" run -i --network host -v "$PWD"/data:/data "$ARCHIVEBOX_IMAGE" add
archive_list="$("$DOCKER_BINARY" run -v "$PWD"/data:/data "$ARCHIVEBOX_IMAGE" list)"
[[ "$archive_list" == *"www.test-nginx-1.local"* ]] || { echo "The site 1 isn't in the list"; exit 1; }
[[ "$archive_list" == *"www.test-nginx-2.local"* ]] || { echo "The site 2 isn't in the list"; exit 1; }
"$DOCKER_BINARY" compose run --rm archivebox init
"$DOCKER_BINARY" compose up -d --wait
"$DOCKER_BINARY" network connect archivebox_default www-nginx
index_html="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:8000/public/')"
admin_js="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:8000/static/admin/js/jquery.init.js')"
[[ "$index_html" == *ArchiveBox* ]]
[[ "$admin_js" == *window.django* ]]
"$DOCKER_BINARY" compose run --rm archivebox add --plugins=parse_txt_urls 'http://www-nginx/#test_docker'
index_html="$("$CURL_BINARY" --silent --location 'http://127.0.0.1:8000/public/')"
[[ "$index_html" == *'http://www-nginx/#test_docker'* ]]
"$DOCKER_BINARY" compose down
- name: Validate compressed image size
if: inputs.full_tests && !inputs.push_digests
env:
BUILD_METADATA: ${{ steps.docker_build_pr.outputs.metadata }}
shell: bash
run: |
set -Eeuo pipefail
LOCAL_IMAGE_ID="$("$DOCKER_BINARY" image inspect '${{ matrix.local_tag }}' --format '{{.Id}}')"
"$UV_BINARY" run --no-cache --no-project python - \
'${{ runner.temp }}/archivebox-image.tar' \
'${{ steps.docker_build_pr.outputs.digest }}' \
"$LOCAL_IMAGE_ID" \
"$((750 * 1024 * 1024))" <<'PY'
from hashlib import sha256
import json
import os
import sys
import tarfile
archive_path, pushed_digest, local_image_id, limit_text = sys.argv[1:]
limit = int(limit_text)
metadata = json.loads(os.environ["BUILD_METADATA"])
if metadata["containerimage.digest"] != pushed_digest:
raise SystemExit("Build push transaction digest does not match the action output")
if not pushed_digest.startswith("sha256:") or len(pushed_digest) != 71:
raise SystemExit(f"Invalid pushed digest: {pushed_digest}")
with tarfile.open(archive_path) as archive:
def read_blob(descriptor):
algorithm, digest = descriptor["digest"].split(":", 1)
if algorithm != "sha256":
raise SystemExit(f"Unsupported OCI digest: {descriptor['digest']}")
member = archive.extractfile(f"blobs/sha256/{digest}")
if member is None:
raise SystemExit(f"Missing OCI blob: {descriptor['digest']}")
blob = member.read()
if len(blob) != descriptor["size"]:
raise SystemExit(f"OCI size mismatch: {descriptor['digest']}")
if sha256(blob).hexdigest() != digest:
raise SystemExit(f"OCI digest mismatch: {descriptor['digest']}")
return blob
index_member = archive.extractfile("index.json")
if index_member is None:
raise SystemExit("OCI export is missing index.json")
index = json.load(index_member)
manifests = []
def collect(descriptor):
document = json.loads(read_blob(descriptor))
media_type = descriptor["mediaType"]
if "image.index" in media_type or "manifest.list" in media_type:
for child in document["manifests"]:
collect(child)
elif "image.manifest" in media_type:
config_type = document["config"]["mediaType"]
if "image.config" in config_type or "container.image" in config_type:
manifests.append(document)
for root in index["manifests"]:
collect(root)
if len(manifests) != 1:
raise SystemExit(f"Expected one runnable image manifest, found {len(manifests)}")
manifest = manifests[0]
config_digest = manifest["config"]["digest"]
if metadata["containerimage.config.digest"] != config_digest:
raise SystemExit("OCI config digest does not match the pushed build result")
if local_image_id != config_digest:
raise SystemExit("Locally tested image does not match the OCI build result")
descriptors = [manifest["config"], *manifest["layers"]]
for descriptor in descriptors:
read_blob(descriptor)
total = sum(descriptor["size"] for descriptor in descriptors)
print(
f"{pushed_digest} compressed_size={total // 1024 // 1024} MiB "
f"limit={limit // 1024 // 1024} MiB",
)
if total > limit:
raise SystemExit(f"{pushed_digest} is over the compressed size limit")
PY
- name: Export digest
if: inputs.push_digests
shell: bash
run: |
set -Eeuo pipefail
mkdir -p /tmp/digests
digest="${{ steps.docker_build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
if: inputs.push_digests
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
- name: Update README
uses: peter-evans/dockerhub-description@v4
with:
name: ${{ matrix.artifact_name }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: archivebox/archivebox
# This ugly bit is necessary if you don't want your cache to grow forever
# until it hits GitHub's limit of 5GB.
# Temp fix
# https://github.com/docker/build-push-action/issues/252
# https://github.com/moby/buildkit/issues/1896
- name: Move cache
run: |
rm -rf /tmp/.buildx-cache
mv /tmp/.buildx-cache-new /tmp/.buildx-cache
-158
View File
@@ -1,158 +0,0 @@
name: Documentation tests
on:
workflow_call:
env:
PYTHONIOENCODING: utf-8
USE_COLOR: "False"
SHOW_PROGRESS: "False"
jobs:
discover:
name: Discover documentation examples
runs-on: ubuntu-24.04
timeout-minutes: 5
outputs:
matrix: ${{ steps.inventory.outputs.matrix }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
submodules: true
fetch-depth: 1
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
enable-cache: false
cache-dependency-glob: uv.lock
- name: Install ArchiveBox development dependencies
run: uv sync --no-cache --locked --dev
- name: Resolve documentation tools through abxpkg
env:
ABXPKG_LIB_DIR: ${{ runner.temp }}/archivebox-docs-lib
run: |
export PATH="$ABXPKG_LIB_DIR/env/bin:$PATH"
uv run --no-cache --no-sync abxpkg env \
--install \
--lib="$ABXPKG_LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:ci_binaries" \
--deps-from="$GITHUB_WORKSPACE/docs/mermaid-binary.json:required_binaries" \
--deps-from="$GITHUB_WORKSPACE/docs/nginx-binary.json:required_binaries"
bash_binary="$ABXPKG_LIB_DIR/env/bin/bash"
mmdc_binary="$ABXPKG_LIB_DIR/env/bin/mmdc"
nginx_binary="$ABXPKG_LIB_DIR/env/bin/nginx"
test -L "$bash_binary"
test -L "$mmdc_binary"
test -L "$nginx_binary"
test -x "$bash_binary"
test -x "$mmdc_binary"
test -x "$nginx_binary"
{
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR"
echo "BASH_BINARY=$bash_binary"
} >> "$GITHUB_ENV"
- id: inventory
name: Validate the complete authored documentation inventory
run: |
matrix="$(uv run --no-cache --no-sync python docs/test_codeblocks_manifest.py matrix)"
echo "matrix=$matrix" >> "$GITHUB_OUTPUT"
examples:
name: docs/${{ matrix.name }}
needs: discover
runs-on: ${{ matrix.runner }}
timeout-minutes: 15
strategy:
fail-fast: false
max-parallel: 4
matrix: ${{ fromJSON(needs.discover.outputs.matrix) }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
submodules: true
fetch-depth: 1
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
if: ${{ !contains(fromJSON('["freebsd", "openbsd"]'), matrix.environment) }}
with:
python-version: "3.13"
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
if: ${{ !contains(fromJSON('["freebsd", "openbsd"]'), matrix.environment) }}
with:
version: "0.11.3"
enable-cache: false
cache-dependency-glob: uv.lock
- name: Install ArchiveBox
if: ${{ !contains(fromJSON('["freebsd", "openbsd"]'), matrix.environment) }}
run: uv sync --no-cache --locked --dev
- name: Resolve documentation runtime through abxpkg
if: ${{ !contains(fromJSON('["freebsd", "openbsd"]'), matrix.environment) }}
env:
ABXPKG_LIB_DIR: ${{ runner.temp }}/archivebox-docs-lib
run: |
export PATH="$ABXPKG_LIB_DIR/env/bin:$PATH"
dependency_sources=(
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:ci_binaries"
)
if [[ '${{ matrix.environment }}' != macos ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docs_binaries")
fi
if [[ '${{ matrix.name }}' == ubuntu-AGENTS || '${{ matrix.name }}' == ubuntu-skills-archivebox-SKILL || '${{ matrix.name }}' == ubuntu-docs-Configuration || '${{ matrix.name }}' == ubuntu-docs-Setting-up-Authentication ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-linux-build.json:required_binaries")
fi
if [[ '${{ matrix.environment }}' == docker ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries")
elif [[ '${{ matrix.environment }}' == macos ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-macos-brew.json:required_binaries")
fi
uv run --no-cache --no-sync abxpkg env \
--install \
--lib="$ABXPKG_LIB_DIR" \
"${dependency_sources[@]}"
bash_binary="$ABXPKG_LIB_DIR/env/bin/bash"
python_binary="$ABXPKG_LIB_DIR/env/bin/python"
uv_binary="$ABXPKG_LIB_DIR/env/bin/uv"
test -L "$bash_binary"
test -x "$bash_binary"
test -L "$python_binary"
test -x "$python_binary"
test -L "$uv_binary"
test -x "$uv_binary"
{
echo "ABXPKG_LIB_DIR=$ABXPKG_LIB_DIR"
echo "BASH_BINARY=$bash_binary"
echo "PYTHON_BINARY=$python_binary"
echo "UV_BINARY=$uv_binary"
} >> "$GITHUB_ENV"
if [[ '${{ matrix.environment }}' != macos ]]; then
sudo_binary="$ABXPKG_LIB_DIR/env/bin/sudo"
test -L "$sudo_binary"
test -x "$sudo_binary"
echo "SUDO_BINARY=$sudo_binary" >> "$GITHUB_ENV"
fi
if [[ '${{ matrix.environment }}' != macos ]]; then
echo "$ABXPKG_LIB_DIR/env/bin" >> "$GITHUB_PATH"
fi
if [[ '${{ matrix.environment }}' == docker ]]; then
docker_binary="$ABXPKG_LIB_DIR/env/bin/docker"
test -L "$docker_binary"
test -x "$docker_binary"
echo "DOCKER_BINARY=$docker_binary" >> "$GITHUB_ENV"
elif [[ '${{ matrix.environment }}' == macos ]]; then
test -L "$ABXPKG_LIB_DIR/env/bin/brew"
test -x "$ABXPKG_LIB_DIR/env/bin/brew"
fi
- name: Build the exact checkout's documentation image
if: ${{ matrix.environment == 'docker' }}
run: '"$DOCKER_BINARY" build --tag archivebox/archivebox:dev .'
- name: Run the Ubuntu, Docker, or macOS documentation lane
if: ${{ !contains(fromJSON('["root", "freebsd", "openbsd"]'), matrix.environment) }}
run: uv run --no-cache --no-sync python docs/test_codeblocks_manifest.py run-group '${{ matrix.snippet_ids }}'
- name: Run the root documentation lane
if: ${{ matrix.environment == 'root' }}
run: |
"$SUDO_BINARY" \
--preserve-env=PATH,ABXPKG_LIB_DIR,BASH_BINARY,PYTHON_BINARY,SUDO_BINARY,UV_BINARY \
"$UV_BINARY" run --no-cache --no-sync python docs/test_codeblocks_manifest.py run-group '${{ matrix.snippet_ids }}'
+64
View File
@@ -0,0 +1,64 @@
# Simple workflow for deploying static content to GitHub Pages
name: Deploy static content to Pages
on:
# Runs on pushes targeting the default branch
push:
branches: ["dev"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
# Single deploy job since we're just deploying
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 1
- name: Copy README.md into place
run: |
rm -f ./website/README.md
cp ./README.md ./website/README.md
- name: Setup Pages
uses: actions/configure-pages@v3
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
source: ./website
destination: ./_site
- name: Upload artifact
uses: actions/upload-pages-artifact@v2
# - name: Checkout
# uses: actions/checkout@v4
# - name: Setup Pages
# uses: actions/configure-pages@v5
# - name: Upload artifact
# uses: actions/upload-pages-artifact@v3
# with:
# # Upload entire repository
# path: './website'
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+51
View File
@@ -0,0 +1,51 @@
name: Build Homebrew package
on:
workflow_dispatch:
push:
jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
with:
submodules: true
fetch-depth: 1
# TODO: modify archivebox.rb to update src url, hashes, and dependencies
- name: Build Homebrew Bottle
run: |
pip3 install --upgrade pip setuptools wheel
cd brew_dist/
brew install --build-bottle ./archivebox.rb
# brew bottle archivebox
archivebox version
- name: Add some links to test
run: |
mkdir data && cd data
archivebox init --setup
archivebox add 'https://example.com'
archivebox version
archivebox status
# - name: Commit built package
# run: |
# cd brew_dist/
# git config --local user.email "action@github.com"
# git config --local user.name "GitHub Action"
# git commit -m "Homebrew package autobuild" -a
# - name: Push build to Github
# uses: ad-m/github-push-action@master
# with:
# github_token: ${{ secrets.GITHUB_TOKEN }}
# repository: ArchiveBox/homebrew-archivebox
# branch: ${{ github.ref }}
# directory: brew_dist
# TODO: push bottle homebrew core PR with latest changes
+58
View File
@@ -0,0 +1,58 @@
# Sample workflow for building and deploying a Jekyll site to GitHub Pages
name: Build GitHub Pages website
on:
# Runs on pushes targeting the default branch
push:
branches: ["dev"]
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: true
jobs:
# Build job
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 1
- name: Copy README.md into place
run: |
rm ./website/README.md
cp ./README.md ./website/README.md
- name: Setup Pages
uses: actions/configure-pages@v3
- name: Build with Jekyll
uses: actions/jekyll-build-pages@v1
with:
source: ./website
destination: ./_site
- name: Upload artifact
uses: actions/upload-pages-artifact@v2
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
needs: build
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v2
+17 -20
View File
@@ -1,38 +1,35 @@
name: Run linters
on:
workflow_call:
workflow_dispatch:
push:
env:
UV_NO_SOURCES: "1"
MAX_LINE_LENGTH: 110
jobs:
lint:
runs-on: ubuntu-22.04
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@v2
with:
submodules: true
fetch-depth: 1
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v1
with:
python-version: "3.13"
python-version: 3.9
architecture: x64
- name: Install uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
enable-cache: false
cache-dependency-glob: uv.lock
- name: Install flake8
run: |
pip install flake8
- name: Verify checked dependency lock
run: uv lock --no-cache --check
- name: Install dependencies with uv
run: uv sync --no-cache --locked --dev --extra sonic --extra debug
- name: Run prek
run: uv run --no-cache --no-sync --no-sources prek run --all-files
- name: Lint with flake8
run: |
cd archivebox
# one pass for show-stopper syntax errors or undefined names
flake8 . --count --show-source --statistics
# one pass for small stylistic things
flake8 . --count --max-line-length="$MAX_LINE_LENGTH" --statistics
Executable → Regular
+52 -217
View File
@@ -1,243 +1,78 @@
name: Build Pip package
on:
workflow_call:
inputs:
full_tests:
description: Run the full package compatibility matrix instead of one release smoke check
required: false
default: true
type: boolean
workflow_dispatch:
push:
env:
UV_VERSION: "0.11.3"
jobs:
build:
name: build distributions
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
runs-on: ubuntu-22.04
- name: Set up Python 3.13
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
architecture: x64
- name: Install uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: ${{ env.UV_VERSION }}
enable-cache: false
cache-dependency-glob: uv.lock
- name: Build every distribution
run: |
set -Eeuo pipefail
uv build --no-cache --all-packages --no-sources --out-dir dist --clear
shopt -s nullglob
artifacts=(dist/*)
wheels=(dist/archivebox-*.whl)
sdists=(dist/archivebox-*.tar.gz)
[[ "${#artifacts[@]}" -gt 0 ]]
[[ "${#wheels[@]}" -eq 1 ]]
[[ "${#sdists[@]}" -eq 1 ]]
printf '%s\n' "$GITHUB_SHA" > dist/COMMIT_SHA
uv run --no-cache --no-project python - <<'PY'
import hashlib
from pathlib import Path
root = Path("dist")
wheels = list(root.glob("archivebox-*.whl"))
sdists = list(root.glob("archivebox-*.tar.gz"))
if len(wheels) != 1 or len(sdists) != 1:
raise SystemExit("Expected exactly one ArchiveBox wheel and one sdist")
artifacts = [root / "COMMIT_SHA", wheels[0], sdists[0]]
checksum_lines = []
for artifact in artifacts:
digest = hashlib.sha256(artifact.read_bytes()).hexdigest()
line = f"{digest} {artifact.name}"
checksum_lines.append(line)
print(line)
Path("dist/SHA256SUMS").write_text("\n".join(checksum_lines) + "\n")
PY
- name: Release wheel import and CLI smoke
if: ${{ !inputs.full_tests }}
shell: bash
run: |
set -Eeuo pipefail
shopt -s nullglob
wheels=(dist/archivebox-*.whl)
[[ "${#wheels[@]}" -eq 1 ]]
uv sync --locked --no-cache --no-sources --no-dev --no-install-project
uv pip install --no-cache --no-deps --python .venv/bin/python "${wheels[0]}"
unset PYTHONPATH
cd "$(mktemp -d)"
VIRTUAL_ENV="$GITHUB_WORKSPACE/.venv" uv run --no-cache --active --no-project python -c 'import archivebox'
VIRTUAL_ENV="$GITHUB_WORKSPACE/.venv" uv run --no-cache --active --no-project archivebox version
- name: Upload every distribution
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with:
name: python-distributions
path: dist/*
if-no-files-found: error
retention-days: 7
install-smoke:
name: install ${{ matrix.artifact }} / Python ${{ matrix.python }} / ${{ matrix.os }}
if: inputs.full_tests
needs: build
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-24.04, macos-15]
python: ["3.13", "3.14"]
artifact: [wheel, sdist]
permissions:
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 1
- name: Set up Python ${{ matrix.python }}
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python }}
python-version: '3.11'
architecture: x64
- name: Install uv
uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
- uses: awalsh128/cache-apt-pkgs-action@latest
with:
version: ${{ env.UV_VERSION }}
enable-cache: false
cache-dependency-glob: uv.lock
packages: ripgrep build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1 gnupg2 curl wget python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps
version: 1.0
- name: Download distributions
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
- uses: pdm-project/setup-pdm@v3
with:
name: python-distributions
path: ${{ runner.temp }}/python-distributions
python-version: '3.11'
cache: true
- name: Install and smoke-test the built ${{ matrix.artifact }}
shell: bash
- name: Install dependencies
run: pdm install --fail-fast --no-lock --dev --group=':all' --no-self
- name: Build package
run: |
set -Eeuo pipefail
shopt -s nullglob
pdm build
case "${{ matrix.artifact }}" in
wheel) candidates=("$RUNNER_TEMP"/python-distributions/archivebox-*.whl) ;;
sdist) candidates=("$RUNNER_TEMP"/python-distributions/archivebox-*.tar.gz) ;;
*) exit 2 ;;
esac
[[ "${#candidates[@]}" -eq 1 ]]
artifact="${candidates[0]}"
- name: Install from build
run: pip install ./dist/archivebox-*.whl
smoke_root="$(mktemp -d "$RUNNER_TEMP/archivebox-package-smoke.XXXXXX")"
trap 'rm -rf "$smoke_root"' EXIT
smoke_env="$smoke_root/env"
data_dir="$smoke_root/data"
mkdir -p "$data_dir"
- name: Add some links to test
run: |
mkdir data && cd data
archivebox init
archivebox add 'https://example.com'
archivebox version
archivebox status
dependency_artifacts=()
while IFS= read -r dependency_artifact; do
dependency_artifacts+=("$dependency_artifact")
done < <(uv run --no-cache --no-project python - <<'PY'
import tomllib
#- name: Publish package distributions to PyPI
# run: pdm publish --no-build
locked = {
package["name"]: package
for package in tomllib.load(open("uv.lock", "rb"))["package"]
}
for package_name in ("abx-dl", "abx-plugins", "abxpkg", "abxbus"):
package = locked[package_name]
wheels = package.get("wheels") or []
if len(wheels) != 1:
raise SystemExit(f"Expected exactly one locked wheel for {package_name}")
print(wheels[0]["url"])
PY
)
# - name: Push build to PyPI
# run: |
# cd pip_dist/
# python3 -m twine upload --repository testpypi pip_dist/*.{whl,tar.gz}
# python3 -m twine upload --repository pypi pip_dist/*.{whl,tar.gz}
uv venv --python "${{ matrix.python }}" "$smoke_env"
uv pip install --no-cache \
--python "$smoke_env" \
"${dependency_artifacts[@]}" \
"$artifact"
# - name: Commit built package
# run: |
# cd pip_dist/
# git config --local user.email "action@github.com"
# git config --local user.name "GitHub Action"
# git commit -m "Pip package autobuild" -a
# - name: Push build to Github
# uses: ad-m/github-push-action@master
# with:
# github_token: ${{ secrets.GITHUB_TOKEN }}
# repository: ArchiveBox/pip-archivebox
# branch: ${{ github.ref }}
# directory: pip_dist
unset PYTHONPATH
cd "$data_dir"
VIRTUAL_ENV="$smoke_env" uv run --no-cache --active --no-project --no-sync python - <<'PY'
import os
from pathlib import Path
import archivebox
installed_path = Path(archivebox.__file__).resolve()
environment_path = Path(os.environ["VIRTUAL_ENV"]).resolve()
workspace_path = Path(os.environ["GITHUB_WORKSPACE"]).resolve()
assert installed_path.is_relative_to(environment_path), (installed_path, environment_path)
assert not installed_path.is_relative_to(workspace_path), (installed_path, workspace_path)
print(installed_path)
PY
VIRTUAL_ENV="$smoke_env" uv run --no-cache --active --no-project --no-sync archivebox version
VIRTUAL_ENV="$smoke_env" uv run --no-cache --active --no-project --no-sync archivebox init
VIRTUAL_ENV="$smoke_env" uv run --no-cache --active --no-project --no-sync archivebox status
if [[ "${{ runner.os }}" == "Linux" ]]; then
root_tool_dir="/root/archivebox-package-smoke-${{ matrix.artifact }}-${{ matrix.python }}"
uv_binary="$(command -v uv)"
sudo rm -rf "$root_tool_dir"
sudo mkdir -p "$root_tool_dir/data"
root_install_args=()
for dependency_artifact in "${dependency_artifacts[@]}"; do
root_install_args+=(--with "$dependency_artifact")
done
sudo UV_TOOL_DIR="$root_tool_dir/tools" UV_TOOL_BIN_DIR="$root_tool_dir/bin" \
"$uv_binary" tool install --no-cache --python "${{ matrix.python }}" \
"${root_install_args[@]}" "$artifact"
sudo useradd --system --create-home --home-dir /var/lib/archivebox --shell /bin/bash archivebox 2>/dev/null || true
sudo rm -rf /var/lib/archivebox
sudo env PATH="$root_tool_dir/bin:$PATH" \
bash -c "cd '$root_tool_dir/data' && archivebox init"
sudo stat -c %U /var/lib/archivebox | grep -qx archivebox
sudo stat -c %U "$root_tool_dir/data" | grep -qx archivebox
if sudo find "$root_tool_dir/data" -xdev -user root -print -quit | grep -q .; then
echo "Root-owned files remain in $root_tool_dir/data" >&2
exit 1
fi
sudo mkdir "$root_tool_dir/external"
sudo ln -s "$root_tool_dir/external" "$root_tool_dir/data/external-link"
sudo env PATH="$root_tool_dir/bin:$PATH" \
bash -c "cd '$root_tool_dir/data' && archivebox status"
sudo stat -c %U "$root_tool_dir/external" | grep -qx root
sudo mkdir "$root_tool_dir/data-universal-init"
sudo env PATH="$root_tool_dir/bin:$PATH" \
bash -c "cd '$root_tool_dir/data-universal-init' && archivebox status --init"
sudo stat -c %U "$root_tool_dir/data-universal-init" | grep -qx archivebox
sudo mkdir "$root_tool_dir/data-install"
sudo env PATH="$root_tool_dir/bin:$PATH" \
bash -c "cd '$root_tool_dir/data-install' && archivebox install --dry-run"
sudo stat -c %U "$root_tool_dir/data-install" | grep -qx archivebox
sudo useradd --create-home archivebox-smoke-user
smoke_user_group="$(id -gn archivebox-smoke-user)"
sudo mkdir "$root_tool_dir/data-mixed-owner"
sudo chown archivebox-smoke-user:root "$root_tool_dir/data-mixed-owner"
sudo env PATH="$root_tool_dir/bin:$PATH" \
bash -c "cd '$root_tool_dir/data-mixed-owner' && archivebox init"
sudo stat -c %U:%G "$root_tool_dir/data-mixed-owner" | grep -qx "archivebox-smoke-user:$smoke_user_group"
if sudo find "$root_tool_dir/data-mixed-owner" -xdev -user root -print -quit | grep -q .; then
echo "Root-owned files remain in $root_tool_dir/data-mixed-owner" >&2
exit 1
fi
sudo rm -rf "$root_tool_dir"
fi
-51
View File
@@ -1,51 +0,0 @@
name: Release candidate
on:
push:
branches: [dev]
permissions:
actions: read
contents: write
packages: write
concurrency:
group: release-candidate-dev
cancel-in-progress: true
jobs:
prepare:
runs-on: ubuntu-24.04
outputs:
action: ${{ steps.version.outputs.action }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
submodules: true
token: ${{ secrets.RELEASE_GH_TOKEN }}
- id: version
uses: ArchiveBox/monorepo/.github/actions/prepare-release-version@main
with:
package: archivebox
branch: dev
tag-prefix: v
version-scheme: rc
python-artifacts:
needs: prepare
if: needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved'
uses: ./.github/workflows/pip.yml
with:
full_tests: false
secrets: inherit
docker-digests:
needs: prepare
if: needs.prepare.outputs.action == 'ready' || needs.prepare.outputs.action == 'reserved'
uses: ./.github/workflows/docker.yml
with:
push_digests: true
full_tests: false
secrets: inherit
-331
View File
@@ -1,331 +0,0 @@
name: Release
on:
workflow_run:
workflows: [CI, Release candidate]
types: [completed]
branches: [dev, main]
permissions: {}
concurrency:
group: release-${{ github.repository }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: false
env:
DOCKERHUB_IMAGE: archivebox/archivebox
GHCR_IMAGE: ghcr.io/archivebox/archivebox
RELEASE_BRANCH: ${{ github.event.workflow_run.head_branch }}
RELEASE_SHA: ${{ github.event.workflow_run.head_sha }}
CI_RUN_ID: ${{ github.event.workflow_run.id }}
jobs:
candidate:
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push' &&
((github.event.workflow_run.head_branch == 'dev' && github.event.workflow_run.name == 'Release candidate') ||
(github.event.workflow_run.head_branch == 'main' && github.event.workflow_run.name == 'CI'))
runs-on: ubuntu-24.04
outputs:
ready: ${{ steps.owner.outputs.ready }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
ref: ${{ env.RELEASE_SHA }}
- id: owner
shell: bash
run: |
if [[ "$RELEASE_BRANCH" == main ]]; then
echo 'ready=true' >> "$GITHUB_OUTPUT"
exit 0
fi
VERSION="$(sed -nE 's/^version = "([^"]+)".*/\1/p' pyproject.toml | head -n 1)"
TARGET="$(git ls-remote origin "refs/tags/release-candidate/$VERSION" | awk '{print $1}')"
[[ "$TARGET" == "$RELEASE_SHA" ]] && echo 'ready=true' >> "$GITHUB_OUTPUT" || echo 'ready=false' >> "$GITHUB_OUTPUT"
python-release:
name: Publish tested Python artifacts
needs: candidate
if: needs.candidate.outputs.ready == 'true'
runs-on: ubuntu-24.04
environment: pypi
permissions:
actions: read
contents: write
id-token: write
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
release_ready: ${{ steps.release-state.outputs.ready }}
steps:
- name: Checkout the tested commit
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 0
submodules: true
ref: ${{ env.RELEASE_SHA }}
token: ${{ secrets.RELEASE_GH_TOKEN || github.token }}
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
enable-cache: false
- name: Resolve release binaries through abxpkg
env:
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
shell: bash
run: |
set -Eeuo pipefail
ABXPKG_SPEC="$(uv run --no-cache --no-project python -c 'import tomllib; package = next(item for item in tomllib.load(open("uv.lock", "rb"))["package"] if item["name"] == "abxpkg"); wheel = package["wheels"][0]; print("abxpkg @ {}#{}".format(wheel["url"], wheel["hash"].replace(":", "=")))')"
mkdir -p "$ABXPKG_LIB_DIR/env/bin"
uv run --no-cache --no-project --with "$ABXPKG_SPEC" abxpkg env --install \
--lib="$ABXPKG_LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:release_binaries" >/dev/null
for name in uv gh git jq curl; do
test -L "$ABXPKG_LIB_DIR/env/bin/$name"
test -x "$ABXPKG_LIB_DIR/env/bin/$name"
done
{
echo "UV_BINARY=$ABXPKG_LIB_DIR/env/bin/uv"
echo "GH_BINARY=$ABXPKG_LIB_DIR/env/bin/gh"
echo "GIT_BINARY=$ABXPKG_LIB_DIR/env/bin/git"
echo "JQ_BINARY=$ABXPKG_LIB_DIR/env/bin/jq"
echo "CURL_BINARY=$ABXPKG_LIB_DIR/env/bin/curl"
} >> "$GITHUB_ENV"
- name: Download exact tested distributions
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-distributions
path: ${{ runner.temp }}/python-distributions
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ env.CI_RUN_ID }}
- name: Record release version
id: version
shell: bash
run: |
set -Eeuo pipefail
VERSION="$($UV_BINARY run --no-cache --no-project python -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "tag=v$VERSION" >> "$GITHUB_OUTPUT"
- name: Validate release branch and version
shell: bash
run: |
set -Eeuo pipefail
VERSION='${{ steps.version.outputs.version }}'
case "$RELEASE_BRANCH" in
main)
;;
dev)
[[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+rc[0-9]+$ ]] || {
echo "Refusing to publish ArchiveBox $VERSION from dev; only archivebox:main may publish non-prerelease versions." >&2
exit 1
}
;;
*)
echo "Refusing to publish ArchiveBox $VERSION from unsupported branch $RELEASE_BRANCH; only archivebox:dev and archivebox:main may publish." >&2
exit 1
;;
esac
- name: Publish the exact tested distributions
env:
GH_TOKEN: ${{ secrets.RELEASE_GH_TOKEN || github.token }}
RELEASE_DISTRIBUTIONS_DIR: ${{ runner.temp }}/python-distributions
run: ./bin/release.sh
- name: Confirm this tested commit owns the release tag
id: release-state
shell: bash
run: |
set -Eeuo pipefail
TARGET=""
while read -r sha _; do
TARGET="$sha"
done < <("$GIT_BINARY" ls-remote origin "refs/tags/${{ steps.version.outputs.tag }}")
if [[ "$TARGET" == "$RELEASE_SHA" ]]; then
echo "ready=true" >> "$GITHUB_OUTPUT"
else
echo "ready=false" >> "$GITHUB_OUTPUT"
fi
docker-release:
name: Publish tested multiarch image
if: >-
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event == 'push'
needs: python-release
runs-on: ubuntu-24.04
permissions:
actions: read
contents: read
packages: write
steps:
- name: Checkout the tested commit
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 1
ref: ${{ env.RELEASE_SHA }}
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: "3.13"
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
enable-cache: false
- name: Resolve release and Docker binaries through abxpkg
env:
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
shell: bash
run: |
set -Eeuo pipefail
ABXPKG_SPEC="$(uv run --no-cache --no-project python -c 'import tomllib; package = next(item for item in tomllib.load(open("uv.lock", "rb"))["package"] if item["name"] == "abxpkg"); wheel = package["wheels"][0]; print("abxpkg @ {}#{}".format(wheel["url"], wheel["hash"].replace(":", "=")))')"
mkdir -p "$ABXPKG_LIB_DIR/env/bin"
uv run --no-cache --no-project --with "$ABXPKG_SPEC" abxpkg env --install \
--lib="$ABXPKG_LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:release_binaries" \
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:docker_binaries" >/dev/null
for name in uv git jq docker; do
test -L "$ABXPKG_LIB_DIR/env/bin/$name"
test -x "$ABXPKG_LIB_DIR/env/bin/$name"
done
{
echo "UV_BINARY=$ABXPKG_LIB_DIR/env/bin/uv"
echo "GIT_BINARY=$ABXPKG_LIB_DIR/env/bin/git"
echo "JQ_BINARY=$ABXPKG_LIB_DIR/env/bin/jq"
echo "DOCKER_BINARY=$ABXPKG_LIB_DIR/env/bin/docker"
} >> "$GITHUB_ENV"
- uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
with:
install: true
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Download exact tested image digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
path: /tmp/digests
pattern: digest-*
merge-multiple: true
github-token: ${{ github.token }}
repository: ${{ github.repository }}
run-id: ${{ env.CI_RUN_ID }}
- name: Verify digest set and collect tags
id: docker_meta
shell: bash
run: |
set -Eeuo pipefail
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))')
[[ "${#DIGESTS[@]}" -eq 2 ]]
for digest in "${DIGESTS[@]}"; do [[ "$digest" =~ ^[0-9a-f]{64}$ ]]; done
VERSION='${{ needs.python-release.outputs.version }}'
SHORT_SHA="${RELEASE_SHA::12}"
TAG_TARGET="$($GIT_BINARY ls-remote origin "refs/tags/v${VERSION}^{}")"
TAG_TARGET="${TAG_TARGET%%[[:space:]]*}"
if [[ -z "$TAG_TARGET" ]]; then
TAG_TARGET="$($GIT_BINARY ls-remote origin "refs/tags/v${VERSION}")"
TAG_TARGET="${TAG_TARGET%%[[:space:]]*}"
fi
{
echo 'dockerhub_tags<<EOF'
if [[ "$RELEASE_BRANCH" == dev ]]; then
echo "${DOCKERHUB_IMAGE}:dev"
elif [[ "$RELEASE_BRANCH" == main ]]; then
echo "${DOCKERHUB_IMAGE}:latest"
fi
echo "${DOCKERHUB_IMAGE}:sha-${SHORT_SHA}"
[[ "$TAG_TARGET" == "$RELEASE_SHA" ]] && echo "${DOCKERHUB_IMAGE}:${VERSION}"
echo 'EOF'
echo 'ghcr_tags<<EOF'
if [[ "$RELEASE_BRANCH" == dev ]]; then
echo "${GHCR_IMAGE}:dev"
elif [[ "$RELEASE_BRANCH" == main ]]; then
echo "${GHCR_IMAGE}:latest"
fi
echo "${GHCR_IMAGE}:sha-${SHORT_SHA}"
[[ "$TAG_TARGET" == "$RELEASE_SHA" ]] && echo "${GHCR_IMAGE}:${VERSION}"
echo 'EOF'
} >> "$GITHUB_OUTPUT"
- name: Create Docker Hub manifest
shell: bash
run: |
set -Eeuo pipefail
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))')
TAG_ARGS=()
while IFS= read -r tag; do [[ -n "$tag" ]] && TAG_ARGS+=(--tag "$tag"); done <<< '${{ steps.docker_meta.outputs.dockerhub_tags }}'
REFS=()
for digest in "${DIGESTS[@]}"; do REFS+=("${DOCKERHUB_IMAGE}@sha256:${digest}"); done
$DOCKER_BINARY buildx imagetools create "${TAG_ARGS[@]}" "${REFS[@]}"
- name: Create GHCR manifest
shell: bash
run: |
set -Eeuo pipefail
mapfile -t DIGESTS < <($UV_BINARY run --no-cache --no-project python -c 'from pathlib import Path; print("\n".join(sorted(path.name for path in Path("/tmp/digests").iterdir() if path.is_file())))')
TAG_ARGS=()
while IFS= read -r tag; do [[ -n "$tag" ]] && TAG_ARGS+=(--tag "$tag"); done <<< '${{ steps.docker_meta.outputs.ghcr_tags }}'
REFS=()
for digest in "${DIGESTS[@]}"; do REFS+=("${GHCR_IMAGE}@sha256:${digest}"); done
$DOCKER_BINARY buildx imagetools create "${TAG_ARGS[@]}" "${REFS[@]}"
- name: Verify published Docker images run
shell: bash
run: |
set -Eeuo pipefail
VERSION='${{ needs.python-release.outputs.version }}'
SHORT_SHA="${RELEASE_SHA::12}"
for image in "${DOCKERHUB_IMAGE}:sha-${SHORT_SHA}" "${GHCR_IMAGE}:sha-${SHORT_SHA}"; do
"$DOCKER_BINARY" buildx imagetools inspect "$image" >/dev/null
INSTALLED_VERSION="$("$DOCKER_BINARY" run --rm "$image" --version)"
test "$INSTALLED_VERSION" = "$VERSION"
done
- name: Update Docker Hub README
uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
repository: archivebox/archivebox
cascade:
name: Cascade verified release
if: needs.python-release.outputs.release_ready == 'true'
needs:
- python-release
- docker-release
permissions:
actions: read
contents: read
uses: ArchiveBox/monorepo/.github/workflows/cascade-release.yml@main
with:
upstream_package: archivebox
upstream_version: ${{ needs.python-release.outputs.version }}
upstream_artifact_name: python-distributions
secrets:
release_token: ${{ secrets.RELEASE_GH_TOKEN }}
-70
View File
@@ -1,70 +0,0 @@
name: Capture publicsite screenshots
on:
push:
branches:
- dev
paths:
- publicsite/**
- archivebox/**
- bin/collect_ui_screenshots.*
- bin/generate_ui_screenshot_gallery.py
- bin/take_screenshot.js
- docs/Screenshots.md
- pyproject.toml
- uv.lock
- .github/workflows/screenshots.yml
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.event_name == 'workflow_dispatch' && format('publicsite-captures-manual-{0}', github.run_id) || 'publicsite-captures' }}
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
fetch-depth: 1
- name: Setup Python and uv
uses: astral-sh/setup-uv@v6
with:
enable-cache: false
- name: Install ArchiveBox and screenshot dependencies
run: uv sync --no-cache --frozen
- name: Build UI screenshot gallery
run: bin/collect_ui_screenshots.sh
env:
BASE_URL: http://archivebox.localhost:8000
- name: Verify gallery was generated from this exact revision
run: |
uv run --no-cache --no-sync python - <<'PY'
import json
import os
import tomllib
from pathlib import Path
provenance = json.loads(Path("publicsite/screenshots/build.json").read_text())
version = tomllib.loads(Path("pyproject.toml").read_text())["project"]["version"]
assert provenance["revision"] == os.environ["GITHUB_SHA"], provenance
assert provenance["version"] == version, provenance
assert provenance["capture_count"] >= 3 * 35, provenance
assert len(provenance["files"]) == provenance["capture_count"], provenance
PY
- name: Save validated screenshots
uses: actions/upload-artifact@v4
with:
name: site-screenshots
path: publicsite/screenshots
if-no-files-found: error
retention-days: 90
-154
View File
@@ -1,154 +0,0 @@
name: Parallel Tests
on:
workflow_call:
env:
PYTHONIOENCODING: utf-8
PYTHONLEGACYWINDOWSSTDIO: utf-8
USE_COLOR: False
UV_NO_SOURCES: "1"
CI_PYTHON_VERSION: "3.13"
jobs:
discover-tests:
name: Discover every test
runs-on: ubuntu-24.04
outputs:
tests: ${{ steps.discover.outputs.tests }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
submodules: true
fetch-depth: 1
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ env.CI_PYTHON_VERSION }}
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
enable-cache: false
cache-dependency-glob: uv.lock
- name: Resolve discovery tools through abxpkg
env:
ABXPKG_LIB_DIR: ${{ runner.temp }}/abx-lib
run: |
set -Eeuo pipefail
ABXPKG_SPEC="$(uv run --no-cache --no-project python -c 'import tomllib; package = next(item for item in tomllib.load(open("uv.lock", "rb"))["package"] if item["name"] == "abxpkg"); wheel = package["wheels"][0]; print("abxpkg @ {}#{}".format(wheel["url"], wheel["hash"].replace(":", "=")))')"
test -n "$ABXPKG_SPEC"
mkdir -p "$ABXPKG_LIB_DIR/env/bin"
export PYTHON_BINARY="$pythonLocation/bin/python"
test -x "$PYTHON_BINARY"
uv run --no-cache --no-project --with "$ABXPKG_SPEC" abxpkg env \
--install \
--lib="$ABXPKG_LIB_DIR" \
--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-tooling.json:ci_binaries"
{
echo "PYTHON_BINARY=$ABXPKG_LIB_DIR/env/bin/python"
echo "JQ_BINARY=$ABXPKG_LIB_DIR/env/bin/jq"
} >> "$GITHUB_ENV"
test -L "$ABXPKG_LIB_DIR/env/bin/python"
test -x "$ABXPKG_LIB_DIR/env/bin/python"
test -L "$ABXPKG_LIB_DIR/env/bin/jq"
test -x "$ABXPKG_LIB_DIR/env/bin/jq"
- name: Discover every test exactly once
id: discover
run: |
set -Eeuo pipefail
mapfile -t output < <("$PYTHON_BINARY" .github/scripts/discover_test_matrix.py)
test "${#output[@]}" -eq 2
echo "tests=${output[1]}" >> "$GITHUB_OUTPUT"
echo "${output[0]}"
"$JQ_BINARY" -e 'length > 0 and length <= 256 and ([.[].paths[]] | length == (unique | length))' <<< "${output[1]}"
tests:
name: ${{ matrix.test.name }}
needs: discover-tests
runs-on: ubuntu-24.04
env:
ABXPKG_LIB_DIR: /tmp/archivebox-lib
CHROME_HEADLESS: "true"
DATA_DIR: /tmp/archivebox-test-data
PERSONAS_DIR: /tmp/abx-personas
CHROME_USER_DATA_DIR: /tmp/abx-personas/Default/chrome_profile
strategy:
fail-fast: false
matrix:
test: ${{ fromJson(needs.discover-tests.outputs.tests) }}
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
submodules: true
fetch-depth: 1
- uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
with:
python-version: ${{ env.CI_PYTHON_VERSION }}
architecture: x64
- uses: astral-sh/setup-uv@d0cc045d04ccac9d8b7881df0226f9e82c39688e # v6
with:
version: "0.11.3"
enable-cache: false
cache-dependency-glob: uv.lock
- name: Resolve optional build dependencies through abxpkg
if: matrix.test.extra != '' || contains(matrix.test.paths_arg, 'test_postgres_backend')
env:
TEST_EXTRA: ${{ matrix.test.extra }}
TEST_PATHS: ${{ matrix.test.paths_arg }}
run: |
set -Eeuo pipefail
ABXPKG_SPEC="$(uv run --no-cache --no-project python -c 'import tomllib; package = next(item for item in tomllib.load(open("uv.lock", "rb"))["package"] if item["name"] == "abxpkg"); wheel = package["wheels"][0]; print("abxpkg @ {}#{}".format(wheel["url"], wheel["hash"].replace(":", "=")))')"
test -n "$ABXPKG_SPEC"
dependency_sources=()
if [[ -n "$TEST_EXTRA" ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-linux-build.json:required_binaries")
fi
if [[ "$TEST_PATHS" == *test_postgres_backend* ]]; then
dependency_sources+=(--deps-from="$GITHUB_WORKSPACE/.github/configs/ci-linux-build.json:postgres_binaries")
fi
uv run --no-cache --no-project --with "$ABXPKG_SPEC" abxpkg env \
--install \
--no-cache \
--json \
--lib="$ABXPKG_LIB_DIR" \
"${dependency_sources[@]}"
- name: Install test dependencies
env:
TEST_EXTRA: ${{ matrix.test.extra }}
run: |
set -Eeuo pipefail
extra_args=()
if [[ -n "$TEST_EXTRA" ]]; then
extra_args=(--extra "$TEST_EXTRA")
fi
uv sync --no-cache --locked --dev "${extra_args[@]}"
- name: Run ${{ matrix.test.name }}
env:
TEST_PATHS: ${{ matrix.test.paths_arg }}
run: |
set -Eeuo pipefail
read -r -a test_paths <<< "$TEST_PATHS"
for test_path in "${test_paths[@]}"; do
test -f "$test_path"
done
for test_path in "${test_paths[@]}"; do
test_name="$(basename "$test_path" .py)"
test_slug="${test_name//[^A-Za-z0-9_.-]/_}"
export DATA_DIR="$RUNNER_TEMP/archivebox-test-data/$test_slug"
export PERSONAS_DIR="$RUNNER_TEMP/abx-personas/$test_slug"
export CHROME_USER_DATA_DIR="$PERSONAS_DIR/Default/chrome_profile"
mkdir -p "$DATA_DIR" "$PERSONAS_DIR" "$RUNNER_TEMP/pytest/$test_slug"
uv run --no-cache --no-sync --no-sources pytest -vs "$test_path" \
--basetemp="$RUNNER_TEMP/pytest/$test_slug"
done
Executable → Regular
+132 -37
View File
@@ -1,63 +1,158 @@
name: Install and CLI Compatibility
on:
workflow_call:
name: Run tests
on: [push]
env:
DOCKER_IMAGE: archivebox-ci
ARCHIVEBOX_IMAGE: archivebox-ci
PYTHONIOENCODING: utf-8
PYTHONLEGACYWINDOWSSTDIO: utf-8
USE_COLOR: False
UV_NO_SOURCES: "1"
jobs:
install_cli_platform:
name: CLI install / ${{ matrix.os_name }} / Python ${{ matrix.python }}
python_tests:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-24.04
os_name: linux
python: "3.14"
- os: macos-15
os_name: macOS
python: "3.13"
- os: macos-15
os_name: macOS
python: "3.14"
os: [ubuntu-22.04]
# os: [ubuntu-22.04, macos-latest, windows-latest]
python: [3.11]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 1
### Set up Python
### Setup Python & JS Languages
- name: Set up Python ${{ matrix.python }}
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python }}
architecture: x64
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7
- name: Set up Node JS
uses: actions/setup-node@v4
with:
version: "0.11.3"
enable-cache: false
cache-dependency-glob: uv.lock
node-version: 20.10.0
- name: Install dependencies with uv
run: uv sync --no-cache --locked --dev
- name: Setup PDM
uses: pdm-project/setup-pdm@v3
with:
python-version: '3.11'
cache: true
- name: ArchiveBox full install check
### Install Python & JS Dependencies
- name: Get pip cache dir
id: pip-cache
run: |
set -Eeuo pipefail
DATA_DIR="$PWD/tests/out/data"
mkdir -p "$DATA_DIR"
uv run --no-cache --directory "$DATA_DIR" --no-sync --no-sources archivebox init
uv run --no-cache --directory "$DATA_DIR" --no-sync --no-sources archivebox install
uv run --no-cache --directory "$DATA_DIR" --no-sync --no-sources archivebox version
uv run --no-cache --directory "$DATA_DIR" --no-sync --no-sources archivebox status
echo "::set-output name=dir::$(pip cache dir)"
- name: Cache pip
uses: actions/cache@v3
id: cache-pip
with:
path: ${{ steps.pip-cache.outputs.dir }}
key: ${{ runner.os }}-${{ matrix.python }}-venv-${{ hashFiles('setup.py') }}
restore-keys: |
${{ runner.os }}-${{ matrix.python }}-venv-
- uses: awalsh128/cache-apt-pkgs-action@latest
with:
packages: ripgrep build-essential python3-dev python3-setuptools libssl-dev libldap2-dev libsasl2-dev zlib1g-dev libatomic1 python3-minimal gnupg2 curl wget python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps
version: 1.0
- name: Install pip dependencies
run: |
python -m pip install --upgrade pip setuptools wheel pytest bottle build
python -m pip install -r requirements.txt
python -m pip install -e .[sonic,ldap]
- name: Get npm cache dir
id: npm-cache
run: |
echo "::set-output name=dir::$GITHUB_WORKSPACE/node_modules"
- name: Cache npm
uses: actions/cache@v3
id: cache-npm
with:
path: ${{ steps.npm-cache.outputs.dir }}
key: ${{ runner.os }}-node_modules-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node_modules
- name: Install npm requirements
run: |
npm install
echo "SINGLEFILE_BINARY=$GITHUB_WORKSPACE/node_modules/.bin/single-file" >> $GITHUB_ENV
echo "READABILITY_BINARY=$GITHUB_WORKSPACE/node_modules/.bin/readability-extractor" >> $GITHUB_ENV
echo "MERCURY_BINARY=$GITHUB_WORKSPACE/node_modules/.bin/mercury-parser" >> $GITHUB_ENV
### Run the tests
- name: Directory listing for debugging
run: |
pwd
ls
- name: Archivebox version
run: |
archivebox version
- name: Test built package with pytest
# TODO: remove this exception for windows once we get tests passing on that platform
if: ${{ !contains(matrix.os, 'windows') }}
run: |
python -m pytest -s --basetemp=tests/out --ignore=archivebox/pkgs
docker_tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: true
fetch-depth: 1
# TODO: as of 2020-11 this helper layer broke, upgrade and re-enable this once it's usable again
# - uses: satackey/action-docker-layer-caching@v0.0.8
- name: Build image
run: |
docker build . -t "$DOCKER_IMAGE"
- name: Init data dir
run: |
mkdir "${{ github.workspace }}/data"
docker run -v "${{ github.workspace }}/data":/data "$DOCKER_IMAGE" init
- name: Run test server
run: |
sudo bash -c 'echo "127.0.0.1 www.test-nginx-1.local www.test-nginx-2.local" >> /etc/hosts'
docker run --name www-nginx -p 80:80 -d nginx
- name: Add link
run: |
docker run -v "$PWD"/data:/data --network host "$DOCKER_IMAGE" add http://www.test-nginx-1.local
- name: Add stdin link
run: |
echo "http://www.test-nginx-2.local" | docker run -i --network host -v "$PWD"/data:/data "$DOCKER_IMAGE" add
- name: List links
run: |
docker run -v "$PWD"/data:/data "$DOCKER_IMAGE" list | grep -q "www.test-nginx-1.local" || { echo "The site 1 isn't in the list"; exit 1; }
docker run -v "$PWD"/data:/data "$DOCKER_IMAGE" list | grep -q "www.test-nginx-2.local" || { echo "The site 2 isn't in the list"; exit 1; }
- name: Start docker-compose stack
run: |
docker-compose run archivebox init
docker-compose up -d
sleep 5
curl --silent --location 'http://127.0.0.1:8000' | grep 'ArchiveBox'
curl --silent --location 'http://127.0.0.1:8000/static/admin/js/jquery.init.js' | grep 'window.django'
- name: Check added urls show up in index
run: |
docker-compose run archivebox add 'http://example.com/#test_docker' --index-only
curl --silent --location 'http://127.0.0.1:8000' | grep 'http://example.com/#test_docker'
docker-compose down || true
-28
View File
@@ -1,22 +1,10 @@
.DS_Store
._*
/.heartbeat.json
/package.json
/package-lock.json
*.pyc
__pycache__/
.mypy_cache/
.eggs/
tests/out/
/docs/screenshots/
/publicsite/screenshots/
# Coverage
.coverage
.coverage.*
coverage.json
coverage/
htmlcov/
# Python and Node dependencies
venv/
@@ -30,7 +18,6 @@ pdm.dev.lock
requirements-dev.txt
# Packaging artifacts
requirements.txt
.pdm-python
.pdm-build
archivebox.egg-info
@@ -40,31 +27,16 @@ dist/
# Data folders
lib/
out/
tmp/
data/
data*/
archivebox/tests/data/
archive/
output/
logs/
/-
/personas/
/sources/
index.sqlite3
queue.sqlite3
*.sqlite*
data.*
.archivebox_id
ArchiveBox.conf
*.stdout
*.stderr
*.log
.tmp/
# vim
*.sw?
.vscode
# Local Claude Code task locks (not project state)
.claude/scheduled_tasks.lock
+3
View File
@@ -0,0 +1,3 @@
[submodule "docs"]
path = docs
url = https://github.com/ArchiveBox/ArchiveBox.wiki.git
-67
View File
@@ -1,67 +0,0 @@
default_language_version:
python: python3.13
repos:
- repo: https://github.com/asottile/yesqa
rev: v1.5.0
hooks:
- id: yesqa
- repo: https://github.com/codespell-project/codespell
rev: v2.4.1
hooks:
- id: codespell
additional_dependencies:
- tomli
- repo: https://github.com/asottile/pyupgrade
rev: v3.20.0
hooks:
- id: pyupgrade
args: [--py313-plus]
- repo: https://github.com/asottile/add-trailing-comma
rev: v3.1.0
hooks:
- id: add-trailing-comma
- repo: local
hooks:
- id: ruff-format
name: ruff-format
entry: uv run --active --no-sync --no-sources ruff format
language: system
types_or: [python, pyi]
- id: ruff-check
name: ruff-check
entry: uv run --active --no-sync --no-sources ruff check --fix
language: system
types_or: [python, pyi]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: check-ast
- id: check-toml
- id: check-yaml
exclude: ^\.github/workflows/homebrew\.yml$
- id: check-json
- id: check-merge-conflict
- id: check-symlinks
- id: destroyed-symlinks
- id: check-case-conflict
- id: check-illegal-windows-names
- id: check-shebang-scripts-are-executable
exclude: ^(archivebox/.*\.py|archivebox/tests/.*\.py|archivebox/personas/export_browser_state\.js)$
- id: mixed-line-ending
- id: fix-byte-order-marker
- id: end-of-file-fixer
- id: detect-private-key
- id: debug-statements
- id: forbid-submodules
exclude: ^docs$
- id: check-added-large-files
args: ["--maxkb=600"]
- id: name-tests-test
args: ["--pytest-test-first"]
exclude: ^archivebox/tests/(data/|fixtures\.py$|migrations_helpers\.py$)
-87
View File
@@ -1,87 +0,0 @@
# ArchiveBox Agent Guide
ArchiveBox is the full self-hosted web archiving app. Keep this repo on the `dev` branch.
## Shared Standards
- Use `uv` and `uv run` for Python commands. Do not use system `python`, direct `.venv/bin/python`, or `pip` commands.
- Prefer existing repo patterns, helper APIs, fixtures, scripts, and command surfaces.
- Keep edits focused and minimal. Do not add wrappers, shims, aliases, or extra abstraction layers unless the current code path requires them.
- Do not weaken assertions, skip tests, xfail tests, or accept flaky behavior.
- No mocks, monkeypatches, fakes, simulated handlers, fake binaries, fake hooks, fake buses, or direct shortcuts around user-facing flows.
- Tests and verification should use real CLI commands, REST/API calls, browser UI flows, real hooks, real installs, real subprocesses, real DB rows, real files, and existing fixtures.
- Assertions must verify real correctness: exit codes, returned values, DB state, filesystem contents, field values, rendered output, and side effects.
- Start behavior fixes with a red failing test when a test is requested or practical.
- Trace root causes from observed behavior. Do not paper over failures with retries, wider timeouts, broad fallbacks, or looser assertions.
- Read `README.md` for the full setup, CLI, Docker, API, and release surface.
## Concurrency Contract
- A collection has one orchestrator at a time. Local PID/process checks may warn about obvious same-machine duplicates, but must not claim to enforce ownership across machines or shared filesystems.
- SQLite remains supported with concurrent short writes from CLI and server processes. Keep the SQLite database on a local filesystem, never NFS/SMB, and use short autocommit/CAS updates instead of long transactions or database locks.
- Network calls, hook execution, filesystem migrations, and other long work belong in the orchestrator. Never hold a database transaction or lock across that work.
- PostgreSQL and shared data directories are the path to future multi-machine scheduling. Coordinate that work through database-backed per-crawl/per-snapshot claims; do not introduce file leases or timer-based orchestrator election.
## Development Setup
```bash
uv sync --dev --all-extras
mkdir -p data
cd data
uv run --project .. archivebox init --install
```
Run collection commands from inside an initialized data directory:
```bash
cd data
uv run --project .. archivebox status
uv run --project .. archivebox add --plugins=parse_txt_urls 'https://example.com/'
uv run --project .. archivebox run
```
## User-Facing Setup
Recommended CLI install:
```bash
uv tool install --python 3.13 --prerelease explicit --upgrade 'git+https://github.com/ArchiveBox/ArchiveBox.git@dev'
mkdir -p ~/archivebox/data
cd ~/archivebox/data
archivebox init --install
archivebox add --plugins=parse_txt_urls 'https://example.com/'
```
Alternative install methods:
- Docker Compose / Docker
- Homebrew
- Debian package
- pip
## Basic Usage
```bash
cd ~/archivebox/data
archivebox version
archivebox help
archivebox status
archivebox install
archivebox add --plugins=parse_txt_urls 'https://example.com/docs-basic-usage'
archivebox list --json --with-headers
archivebox search 'example'
archivebox update --filter-type=domain example.com
archivebox remove --yes --delete --filter-type=exact 'https://example.com/docs-basic-usage'
archivebox run
```
## Verification
Use targeted tests for focused work:
```bash
uv run pytest archivebox/tests/test_cli_add.py::test_add_help_shows_depth_and_tag_options -q
uv run prek run --all-files
```
Releases are published only by `.github/workflows/release.yml` after the complete `dev` CI workflow succeeds. Local development and deployment commands must not publish packages, images, tags, or GitHub releases.
+306 -264
View File
@@ -1,216 +1,24 @@
# syntax=docker/dockerfile:1.7
# This is the Dockerfile for ArchiveBox, it bundles the following dependencies:
# python3, ArchiveBox, curl, wget, git, chromium, youtube-dl, yt-dlp, single-file
# Usage:
# git submodule update --init --recursive
# git pull --recurse-submodules
# docker build . -t archivebox --no-cache
# docker run -v "$PWD/data":/data archivebox init
# docker run -v "$PWD/data":/data archivebox add 'https://example.com'
# docker run -v "$PWD/data":/data -it archivebox manage createsuperuser
# docker run -v "$PWD/data":/data -p 8000:8000 archivebox server
# Multi-arch build:
# docker buildx create --use
# docker buildx build . --platform=linux/amd64,linux/arm64--push -t archivebox/archivebox:0.7.5 -t archivebox/archivebox:dev
#
# Read more about [developing Archivebox](https://github.com/ArchiveBox/ArchiveBox#archivebox-development).
# Multistage ArchiveBox Dockerfile that consumes the abx-dl runtime image.
# abx-dl owns Python, Node, Chromium, and downloader plugin runtimes.
# ArchiveBox owns sonic, supervisor, Django, and the app runtime.
# Build abx-dl first, then point this file at it:
# docker buildx build ../abx-dl -f ../abx-dl/Dockerfile \
# --build-context abxbus=../abxbus \
# --build-context abxpkg=../abxpkg \
# --build-context abx-plugins=../abx-plugins \
# -t archivebox/abx-dl:dev
# docker buildx build . -f Dockerfile \
# --build-arg ABX_DL_IMAGE=archivebox/abx-dl:1.12.289 \
# -t archivebox:multistage
#########################################################################################
ARG ABX_DL_IMAGE=archivebox/abx-dl:1.12.289
FROM archivebox/sonic:1.4.9 AS sonic
FROM ${ABX_DL_IMAGE} AS archivebox-runtime-base
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
ARG ARCHIVEBOX_COMMIT_HASH=""
ENV TZ=UTC \
LANGUAGE=en_US:en \
LC_ALL=C.UTF-8 \
LANG=C.UTF-8 \
DEBIAN_FRONTEND=noninteractive \
APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 \
PYTHONIOENCODING=UTF-8 \
PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_NO_COMPILE=1 \
PIP_ONLY_BINARY=aiohttp \
npm_config_loglevel=error
ENV PYTHON_VERSION=3.13 \
NODE_VERSION=24
ENV ARCHIVEBOX_USER=archivebox \
DEFAULT_ARCHIVEBOX_UID=911 \
DEFAULT_ARCHIVEBOX_GID=911 \
IN_DOCKER=True
ENV CODE_DIR=/app \
DATA_DIR=/data \
CONFIG_DIR=/opt/archivebox \
ABXPKG_LIB_DIR=/opt/archivebox/lib \
PLAYWRIGHT_BROWSERS_PATH=/opt/archivebox/lib/playwright/cache \
PERSONAS_DIR=/data/personas \
CHROME_HEADLESS=true \
CHROME_SANDBOX=false \
CHROME_ISOLATION=crawl \
CHROME_ARGS_EXTRA='["--disable-gpu","--disable-features=Translate,OptimizationGuideModelDownloading,MediaRouter"]'
ENV TMP_DIR=/tmp/archivebox \
PIP_VENV_PYTHON=/venv/bin/python3 \
GOOGLE_API_KEY=no \
GOOGLE_DEFAULT_CLIENT_ID=no \
GOOGLE_DEFAULT_CLIENT_SECRET=no
ENV HOME=/home/archivebox \
XDG_CONFIG_HOME=/home/archivebox/.config \
XDG_CACHE_HOME=/opt/archivebox/lib/cache \
TIMEOUT=600
ENV UV_COMPILE_BYTECODE=false \
UV_PYTHON_PREFERENCE=managed \
UV_PYTHON_INSTALL_DIR=/opt/uv/python \
UV_LINK_MODE=copy \
UV_PROJECT_ENVIRONMENT=/venv \
VIRTUAL_ENV=/venv \
PATH="/venv/bin:/opt/node/bin:$PATH"
SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "errtrace", "-o", "nounset", "-c"]
WORKDIR "$CODE_DIR"
RUN cp /VERSION.txt /ABX-DL-VERSION.txt \
&& (echo "[i] Docker build for ArchiveBox multistage starting..." \
&& echo "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) (${TARGETARCH} ${TARGETVARIANT})" \
&& echo "BUILD_START_TIME=$(date +"%Y-%m-%d %H:%M:%S %s") TZ=${TZ} LANG=${LANG}" \
&& uname -a \
&& sed -n '1,7p' /etc/os-release \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abxpkg load --binproviders=env node \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abxpkg load --binproviders=env uv \
) | tee -a /VERSION.txt
FROM archivebox-runtime-base AS archivebox-builder
WORKDIR "$CODE_DIR"
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
--mount=type=bind,source=pyproject.toml,target=/app/pyproject.toml \
<<'EOF'
echo "[+] UV Installing ArchiveBox dependencies from pyproject.toml..."
export UV_NO_CACHE=true
rm -f /etc/apt/apt.conf.d/docker-clean
apt-get update -qq
apt-get install -qq -y --no-install-recommends x11-utils
# Extend abx-dl's existing venv instead of clearing it. Clearing and later
# copying a complete replacement over the base duplicates every venv byte in
# the final overlay history even when most packages are unchanged.
# Do not rewrite inherited native libraries merely to strip them again.
/usr/bin/find /venv/lib/python3.*/site-packages -type f -name '*.so' -print > /tmp/archivebox-inherited-libraries
mkdir -p /tmp/archivebox-uv-project
/venv/bin/python - <<'PY'
from pathlib import Path
import json
import re
import urllib.request
source = Path("/app/pyproject.toml")
target = Path("/tmp/archivebox-uv-project/pyproject.toml")
text = source.read_text()
text = text.replace(
'environments = ["sys_platform == \'darwin\'", "sys_platform == \'linux\'"]',
'environments = ["sys_platform == \'linux\'"]',
)
# Docker builds need the just-published internal abx wheels immediately, but
# PyPI simple can lag the version JSON endpoints by tens of minutes. Generate a
# Docker-only dependency view from the version JSON so the published package
# metadata stays normal while image builds remain resumable after a release.
for package in ("abxbus", "abxpkg", "abx-plugins", "abx-dl"):
match = re.search(
rf'"{re.escape(package)}(?P<operator>==|>=)(?P<version>[^"]+)"',
text,
)
if not match:
continue
version = match.group("version")
with urllib.request.urlopen(f"https://pypi.org/pypi/{package}/{version}/json", timeout=20) as response:
data = json.load(response)
wheel_url = next(url["url"] for url in data["urls"] if url["filename"].endswith(".whl"))
text = re.sub(
rf'"{re.escape(package)}(?:==|>=)[^"]+"',
f'"{package} @ {wheel_url}"',
text,
count=1,
)
target.write_text(text)
PY
/usr/bin/uv sync \
--project /tmp/archivebox-uv-project \
--no-cache \
--no-dev \
--inexact \
--no-install-project \
--no-install-workspace \
--no-install-package abxbus \
--no-install-package abxpkg \
--no-install-package abx-plugins \
--no-install-package abx-dl \
--no-sources
builder_abxpkg_lib_dir=/tmp/archivebox-builder-abxpkg
ABXPKG_NO_CACHE=True abxpkg env --install --binproviders=env,apt --lib="$builder_abxpkg_lib_dir" --overrides='{"apt":{"install_args":["binutils"]}}' strip >/dev/null
/usr/bin/find /venv/lib/python3.*/site-packages -type f -name '*.so' -print0 > /tmp/archivebox-native-libraries
while IFS= read -r -d '' native_library; do
if grep -Fxq "$native_library" /tmp/archivebox-inherited-libraries; then
continue
fi
magic=''
if IFS= read -r -N 4 magic < "$native_library" && [[ "$magic" == $'\x7fELF' ]]; then
"$builder_abxpkg_lib_dir/env/bin/strip" --strip-unneeded "$native_library" || exit $?
fi
done < /tmp/archivebox-native-libraries
rm -f /tmp/archivebox-native-libraries /tmp/archivebox-inherited-libraries
rm -f /venv/bin/uv /venv/bin/uvx
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get purge -y binutils
abxpkg run --binproviders=env --lib="$builder_abxpkg_lib_dir" apt-get autoremove -y
rm -rf "$builder_abxpkg_lib_dir"
rm -rf /venv/lib/python3.*/site-packages/pip* \
/venv/lib/python3.*/site-packages/wheel* \
/venv/bin/pip /venv/bin/pip3 /venv/bin/pip3.* /venv/bin/wheel
rm -rf /var/lib/apt/lists/* /tmp/archivebox-uv-project
EOF
COPY --chown=root:root --chmod=755 "." "$CODE_DIR/"
# Compile only ArchiveBox's installed package. Touching all of /venv here would
# copy every inherited abx-dl file into a new layer merely to change its mtime.
RUN echo "[*] Installing ArchiveBox Python source code from $CODE_DIR..." \
&& COMMIT_HASH="$( \
if [[ "$ARCHIVEBOX_COMMIT_HASH" =~ ^[0-9a-fA-F]{40}$ ]]; then \
echo "$ARCHIVEBOX_COMMIT_HASH"; \
elif [[ -f "$CODE_DIR/.git/HEAD" ]]; then \
HEAD_REF="$(cat "$CODE_DIR/.git/HEAD")"; \
if [[ "$HEAD_REF" =~ ^[0-9a-fA-F]{40}$ ]]; then \
echo "$HEAD_REF"; \
elif [[ "$HEAD_REF" == ref:\ * ]]; then \
REF_PATH="${HEAD_REF#ref: }"; \
cat "$CODE_DIR/.git/$REF_PATH" 2>/dev/null || awk -v ref="$REF_PATH" '$2 == ref {print $1}' "$CODE_DIR/.git/packed-refs" 2>/dev/null || true; \
fi; \
fi)" \
&& if [[ "$COMMIT_HASH" =~ ^[0-9a-fA-F]{40}$ ]]; then echo "COMMIT_HASH=$COMMIT_HASH" | tee -a /VERSION.txt; fi \
&& /usr/bin/uv pip install --no-cache --no-deps "$CODE_DIR" \
&& rm -f /venv/bin/uv /venv/bin/uvx \
&& cd / \
&& ARCHIVEBOX_PY_DIR="$(/venv/bin/python -c 'import pathlib, archivebox; print(pathlib.Path(archivebox.__file__).parent)')" \
&& /venv/bin/python -m compileall --invalidation-mode checked-hash -q "$ARCHIVEBOX_PY_DIR" \
&& test -f "$(/venv/bin/python -c 'import archivebox; print(archivebox.__cached__)')" \
&& /usr/bin/uv pip show archivebox | tee -a /VERSION.txt
# The builder installs and purges stripping tools in one layer, so its final
# filesystem is already runtime-clean. Preserve that ancestry: starting again
# from archivebox-runtime-base and COPYing /venv would bake a second full venv
# over the inherited one instead of recording only ArchiveBox's package delta.
FROM archivebox-builder
# Use Debian 12 w/ faster package updates: https://packages.debian.org/bookworm-backports/
FROM python:3.11-slim-bookworm
# FROM debian:bookworm-backports
LABEL name="archivebox" \
maintainer="Nick Sweeting <dockerfile@archivebox.io>" \
@@ -221,71 +29,305 @@ LABEL name="archivebox" \
org.opencontainers.image.vendor="ArchiveBox" \
org.opencontainers.image.description="All-in-one self-hosted internet archiving solution" \
org.opencontainers.image.source="https://github.com/ArchiveBox/ArchiveBox" \
com.docker.image.source.entrypoint="Dockerfile"
com.docker.image.source.entrypoint="Dockerfile" \
# TODO: release ArchiveBox as a Docker Desktop extension (requires these labels):
# https://docs.docker.com/desktop/extensions-sdk/architecture/metadata/
com.docker.desktop.extension.api.version=">= 1.4.7" \
com.docker.desktop.extension.icon="https://archivebox.io/icon.png" \
com.docker.extension.publisher-url="https://archivebox.io" \
com.docker.extension.screenshots='[{"alt": "Screenshot of Admin UI", "url": "https://github.com/ArchiveBox/ArchiveBox/assets/511499/e8e0b6f8-8fdf-4b7f-8124-c10d8699bdb2"}]' \
com.docker.extension.detailed-description='See here for detailed documentation: https://wiki.archivebox.io' \
com.docker.extension.changelog='See here for release notes: https://github.com/ArchiveBox/ArchiveBox/releases' \
com.docker.extension.categories='database,utility-tools'
COPY --from=sonic /usr/local/bin/sonic /usr/local/bin/sonic
COPY --chown=root:root --chmod=755 "etc/sonic.cfg" /etc/sonic.cfg
ARG TARGETPLATFORM
ARG TARGETOS
ARG TARGETARCH
ARG TARGETVARIANT
# The builder invokes abxpkg as root for temporary ELF stripping, which can
# rewrite derived state ownership. Restore UID 911 before resolving binaries
# as the runtime user; the old copy-based final stage hid this dependency.
RUN --mount=type=cache,target=/opt/archivebox/lib/cache,sharing=locked,mode=1777,id=archivebox-runtime-config-$TARGETARCH \
echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_ARCHIVEBOX_UID}..." \
&& printf 'export PATH="/venv/bin:/opt/node/bin:$PATH"\n' > /etc/profile.d/archivebox-path.sh \
&& ln -sf /venv/bin/archivebox /usr/local/bin/archivebox \
&& ln -sf /venv/bin/daphne /usr/local/bin/daphne \
&& ln -sf /venv/bin/supervisord /usr/local/bin/supervisord \
&& ln -sf /venv/bin/supervisorctl /usr/local/bin/supervisorctl \
&& getent group "$ARCHIVEBOX_USER" >/dev/null || groupadd --system "$ARCHIVEBOX_USER" \
&& id -u "$ARCHIVEBOX_USER" >/dev/null 2>&1 || useradd --system --create-home --gid "$ARCHIVEBOX_USER" --groups audio,video "$ARCHIVEBOX_USER" \
&& usermod --append --groups audio,video "$ARCHIVEBOX_USER" \
&& [[ "$(id -u "$ARCHIVEBOX_USER")" == "$DEFAULT_ARCHIVEBOX_UID" ]] || usermod -u "$DEFAULT_ARCHIVEBOX_UID" "$ARCHIVEBOX_USER" \
&& [[ "$(id -g "$ARCHIVEBOX_USER")" == "$DEFAULT_ARCHIVEBOX_GID" ]] || groupmod -g "$DEFAULT_ARCHIVEBOX_GID" "$ARCHIVEBOX_USER" \
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" "$CONFIG_DIR" "$ABXPKG_LIB_DIR" "$XDG_CACHE_HOME" "$PLAYWRIGHT_BROWSERS_PATH" \
&& install -d -o "$DEFAULT_ARCHIVEBOX_UID" -g "$DEFAULT_ARCHIVEBOX_GID" "/home/$ARCHIVEBOX_USER" \
&& chown "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" "$DATA_DIR" "$TMP_DIR" \
&& find "$ABXPKG_LIB_DIR" \( ! -user "$DEFAULT_ARCHIVEBOX_UID" -o ! -group "$DEFAULT_ARCHIVEBOX_GID" \) -exec chown -h "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" {} + \
&& UV_NO_CACHE=true setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups abxpkg load --binproviders=env sonic | tee -a /VERSION.txt \
&& openssl rand -hex 16 > /etc/machine-id \
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER ARCHIVEBOX_UID=$(id -u "$ARCHIVEBOX_USER") ARCHIVEBOX_GID=$(id -g "$ARCHIVEBOX_USER")" | tee -a /VERSION.txt \
&& echo -e "TMP_DIR=$TMP_DIR\nABXPKG_LIB_DIR=$ABXPKG_LIB_DIR\nPLAYWRIGHT_BROWSERS_PATH=$PLAYWRIGHT_BROWSERS_PATH\nMACHINE_ID=$(cat /etc/machine-id)\n" | tee -a /VERSION.txt
######### Environment Variables #################################
WORKDIR "$DATA_DIR"
RUN echo "[+] Initializing image collection..." \
&& find "$DATA_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} + \
&& HOME="$TMP_DIR" archivebox init \
&& (chown "$DEFAULT_ARCHIVEBOX_UID:$DEFAULT_ARCHIVEBOX_GID" \
"$DATA_DIR" "$DATA_DIR"/.archivebox_id "$DATA_DIR"/ArchiveBox.conf "$DATA_DIR"/index.sqlite3 \
"$DATA_DIR"/logs "$DATA_DIR"/logs/* "$DATA_DIR"/sources \
"$DATA_DIR"/archive "$DATA_DIR"/archive/users "$DATA_DIR"/personas \
"$DATA_DIR"/tmp "$DATA_DIR"/tmp/* \
"$CONFIG_DIR" "$CONFIG_DIR"/config.env "$CONFIG_DIR"/derived.env \
"$TMP_DIR" "$ABXPKG_LIB_DIR" "$XDG_CACHE_HOME" "$PLAYWRIGHT_BROWSERS_PATH" \
2>/dev/null || true) \
&& find "$TMP_DIR" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
# Global system-level config
ENV TZ=UTC \
LANGUAGE=en_US:en \
LC_ALL=C.UTF-8 \
LANG=C.UTF-8 \
DEBIAN_FRONTEND=noninteractive \
APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=1 \
PYTHONIOENCODING=UTF-8 \
PYTHONUNBUFFERED=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
npm_config_loglevel=error
RUN chmod +x "$CODE_DIR"/bin/*.sh \
&& chmod g+w "$TMP_DIR" "$ABXPKG_LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH"
# Version config
ENV PYTHON_VERSION=3.11 \
NODE_VERSION=22
RUN for forbidden_bin in gcc g++ make; do ! abxpkg load --binproviders=env "$forbidden_bin" >/dev/null 2>&1 || (echo "Unexpected build tool in runtime: $forbidden_bin" >&2 && exit 1); done \
&& stat -c "%U:%G %a %n" "$CONFIG_DIR" "$ABXPKG_LIB_DIR" "$PLAYWRIGHT_BROWSERS_PATH" \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "$CONFIG_DIR" \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups test -w "$ABXPKG_LIB_DIR" \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups archivebox install \
&& setpriv --reuid="$ARCHIVEBOX_USER" --regid="$ARCHIVEBOX_USER" --init-groups archivebox version 2>&1 | tee -a /VERSION.txt \
&& rm -rf /root/.cache /var/cache/apt/* /var/lib/apt/lists/*
# User config
ENV ARCHIVEBOX_USER="archivebox" \
DEFAULT_PUID=911 \
DEFAULT_PGID=911 \
IN_DOCKER=True
RUN (echo -e "\n\n[√] Finished ArchiveBox multistage Docker build successfully." \
&& echo -e "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) (${TARGETARCH} ${TARGETVARIANT})" \
&& echo -e "BUILD_END_TIME=$(date +"%Y-%m-%d %H:%M:%S %s")\n\n" \
# Global paths
ENV CODE_DIR=/app \
DATA_DIR=/data \
PLAYWRIGHT_BROWSERS_PATH=/browsers
# GLOBAL_VENV=/venv \
# TODO: add TMP_DIR and LIB_DIR?
# Build shell config
SHELL ["/bin/bash", "-o", "pipefail", "-o", "errexit", "-o", "errtrace", "-o", "nounset", "-c"]
######### System Environment ####################################
# Detect ArchiveBox version number by reading pyproject.toml
COPY --chown=root:root --chmod=755 pyproject.toml "$CODE_DIR/"
RUN grep '^version = ' "${CODE_DIR}/pyproject.toml" | awk -F'"' '{print $2}' > /VERSION.txt
# Force apt to leave downloaded binaries in /var/cache/apt (massively speeds up Docker builds)
RUN echo 'Binary::apt::APT::Keep-Downloaded-Packages "1";' > /etc/apt/apt.conf.d/99keep-cache \
&& echo 'APT::Install-Recommends "0";' > /etc/apt/apt.conf.d/99no-intall-recommends \
&& echo 'APT::Install-Suggests "0";' > /etc/apt/apt.conf.d/99no-intall-suggests \
&& rm -f /etc/apt/apt.conf.d/docker-clean
# Print debug info about build and save it to disk, for human eyes only, not used by anything else
RUN (echo "[i] Docker build for ArchiveBox $(cat /VERSION.txt) starting..." \
&& echo "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) ($(uname -s) ${TARGETARCH} ${TARGETVARIANT})" \
&& echo "BUILD_START_TIME=$(date +"%Y-%m-%d %H:%M:%S %s") TZ=${TZ} LANG=${LANG}" \
&& echo \
&& echo "PYTHON=${PYTHON_VERSION} NODE=${NODE_VERSION} PATH=${PATH}" \
&& echo "CODE_DIR=${CODE_DIR} DATA_DIR=${DATA_DIR}" \
&& echo \
&& uname -a \
&& cat /etc/os-release | head -n7 \
&& which bash && bash --version | head -n1 \
&& which dpkg && dpkg --version | head -n1 \
&& echo -e '\n\n' && env && echo -e '\n\n' \
) | tee -a /VERSION.txt
# Create non-privileged user for archivebox and chrome
RUN echo "[*] Setting up $ARCHIVEBOX_USER user uid=${DEFAULT_PUID}..." \
&& groupadd --system $ARCHIVEBOX_USER \
&& useradd --system --create-home --gid $ARCHIVEBOX_USER --groups audio,video $ARCHIVEBOX_USER \
&& usermod -u "$DEFAULT_PUID" "$ARCHIVEBOX_USER" \
&& groupmod -g "$DEFAULT_PGID" "$ARCHIVEBOX_USER" \
&& echo -e "\nARCHIVEBOX_USER=$ARCHIVEBOX_USER PUID=$(id -u $ARCHIVEBOX_USER) PGID=$(id -g $ARCHIVEBOX_USER)\n\n" \
| tee -a /VERSION.txt
# DEFAULT_PUID and DEFAULT_PID are overriden by PUID and PGID in /bin/docker_entrypoint.sh at runtime
# https://docs.linuxserver.io/general/understanding-puid-and-pgid
# Install system apt dependencies (adding backports to access more recent apt updates)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing APT base system dependencies for $TARGETPLATFORM..." \
&& echo 'deb https://deb.debian.org/debian bookworm-backports main contrib non-free' > /etc/apt/sources.list.d/backports.list \
&& mkdir -p /etc/apt/keyrings \
&& apt-get update -qq \
&& apt-get install -qq -y -t bookworm-backports \
# 1. packaging dependencies
apt-transport-https ca-certificates apt-utils gnupg2 curl wget \
# 2. docker and init system dependencies
zlib1g-dev dumb-init gosu cron unzip grep dnsutils \
# 3. frivolous CLI helpers to make debugging failed archiving easier
tree nano iputils-ping \
# nano iputils-ping dnsutils htop procps jq yq
&& rm -rf /var/lib/apt/lists/*
# Install sonic search backend
COPY --from=archivebox/sonic:1.4.9 /usr/local/bin/sonic /usr/local/bin/sonic
COPY --chown=root:root --chmod=755 "etc/sonic.cfg" /etc/sonic.cfg
RUN (which sonic && sonic --version) | tee -a /VERSION.txt
######### Language Environments ####################################
# Install Python environment
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT --mount=type=cache,target=/root/.cache/pip,sharing=locked,id=pip-$TARGETARCH$TARGETVARIANT \
echo "[+] Setting up Python $PYTHON_VERSION runtime..." \
# NOT NEEDED because we're using a pre-built python image, keeping this here in case we switch back to custom-building our own:
# && apt-get update -qq \
# && apt-get install -qq -y -t bookworm-backports --no-upgrade \
# python${PYTHON_VERSION} python${PYTHON_VERSION}-minimal python3-pip python${PYTHON_VERSION}-venv pipx \
# && rm -rf /var/lib/apt/lists/* \
# tell PDM to allow using global system python site packages
# && rm /usr/lib/python3*/EXTERNALLY-MANAGED \
# && ln -s "$(which python${PYTHON_VERSION})" /usr/bin/python \
# create global virtual environment GLOBAL_VENV to use (better than using pip install --global)
# && python3 -m venv --system-site-packages --symlinks $GLOBAL_VENV \
# && python3 -m venv --system-site-packages $GLOBAL_VENV \
# && python3 -m venv $GLOBAL_VENV \
# install global dependencies / python build dependencies in GLOBAL_VENV
# && pip install --upgrade pip setuptools wheel \
# Save version info
&& ( \
which python3 && python3 --version | grep " $PYTHON_VERSION" \
&& which pip && pip --version \
# && which pdm && pdm --version \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
# Install Node environment
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT --mount=type=cache,target=/root/.npm,sharing=locked,id=npm-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing Node $NODE_VERSION environment..." \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_VERSION}.x nodistro main" >> /etc/apt/sources.list.d/nodejs.list \
&& curl -fsSL "https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key" | gpg --dearmor | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& apt-get update -qq \
&& apt-get install -qq -y -t bookworm-backports --no-upgrade libatomic1 \
&& apt-get install -y -t bookworm-backports --no-upgrade \
nodejs \
&& rm -rf /var/lib/apt/lists/* \
# Update NPM to latest version
&& npm i -g npm --cache /root/.npm \
# Save version info
&& ( \
which node && node --version \
&& which npm && npm --version \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
######### Extractor Dependencies ##################################
# Install apt dependencies
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT --mount=type=cache,target=/root/.cache/pip,sharing=locked,id=pip-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing APT extractor dependencies globally using apt..." \
&& apt-get update -qq \
&& apt-get install -qq -y -t bookworm-backports \
curl wget git ffmpeg ripgrep \
# Packages we have also needed in the past:
# youtube-dl wget2 aria2 python3-pyxattr rtmpdump libfribidi-bin mpv \
&& rm -rf /var/lib/apt/lists/* \
# Save version info
&& ( \
which curl && curl --version | head -n1 \
&& which wget && wget --version 2>&1 | head -n1 \
&& which git && git --version 2>&1 | head -n1 \
&& which rg && rg --version 2>&1 | head -n1 \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
# Install chromium browser using playwright
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT --mount=type=cache,target=/root/.cache/pip,sharing=locked,id=pip-$TARGETARCH$TARGETVARIANT --mount=type=cache,target=/root/.cache/ms-playwright,sharing=locked,id=browsers-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing Browser binary dependencies to $PLAYWRIGHT_BROWSERS_PATH..." \
&& apt-get update -qq \
&& apt-get install -qq -y -t bookworm-backports \
fontconfig fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-khmeros fonts-kacst fonts-symbola fonts-noto fonts-freefont-ttf \
at-spi2-common fonts-liberation fonts-noto-color-emoji fonts-tlwg-loma-otf fonts-unifont libatk-bridge2.0-0 libatk1.0-0 libatspi2.0-0 libavahi-client3 \
libavahi-common-data libavahi-common3 libcups2 libfontenc1 libice6 libnspr4 libnss3 libsm6 libunwind8 \
libxaw7 libxcomposite1 libxdamage1 libxfont2 \
libxkbfile1 libxmu6 libxpm4 libxt6 x11-xkb-utils x11-utils xfonts-encodings \
# xfonts-scalable xfonts-utils xserver-common xvfb \
# chrome can run without dbus/upower technically, it complains about missing dbus but should run ok anyway
# libxss1 dbus dbus-x11 upower \
# && service dbus start \
# install Chromium using playwright
&& pip install playwright \
&& cp -r /root/.cache/ms-playwright "$PLAYWRIGHT_BROWSERS_PATH" \
&& playwright install chromium \
&& export CHROME_BINARY="$(python -c 'from playwright.sync_api import sync_playwright; print(sync_playwright().start().chromium.executable_path)')" \
&& rm -rf /var/lib/apt/lists/* \
&& ln -s "$CHROME_BINARY" /usr/bin/chromium-browser \
&& mkdir -p "/home/${ARCHIVEBOX_USER}/.config/chromium/Crash Reports/pending/" \
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "/home/${ARCHIVEBOX_USER}/.config" \
&& mkdir -p "$PLAYWRIGHT_BROWSERS_PATH" \
&& chown -R $ARCHIVEBOX_USER "$PLAYWRIGHT_BROWSERS_PATH" \
# Save version info
&& ( \
which chromium-browser && /usr/bin/chromium-browser --version || /usr/lib/chromium/chromium --version \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
# Install Node dependencies
ENV PATH="/home/$ARCHIVEBOX_USER/.npm/bin:$PATH"
USER $ARCHIVEBOX_USER
WORKDIR "/home/$ARCHIVEBOX_USER/.npm"
RUN --mount=type=cache,target=/home/$ARCHIVEBOX_USER/.npm_cache,sharing=locked,id=npm-$TARGETARCH$TARGETVARIANT,uid=$DEFAULT_PUID,gid=$DEFAULT_PGID \
echo "[+] Installing NPM extractor dependencies in /home/$ARCHIVEBOX_USER/.npm..." \
&& npm config set prefix "/home/$ARCHIVEBOX_USER/.npm" \
&& npm install --global --prefer-offline --no-fund --no-audit --cache "/home/$ARCHIVEBOX_USER/.npm_cache" \
"@postlight/parser@^2.2.3" \
"readability-extractor@github:ArchiveBox/readability-extractor" \
"single-file-cli@^1.1.54" \
"puppeteer@^23.5.0" \
"@puppeteer/browsers@^2.4.0"
USER root
RUN ( \
which node && node --version \
&& which npm && npm version \
&& which postlight-parser \
&& which readability-extractor && readability-extractor --version \
&& which single-file && single-file --version \
&& which puppeteer && puppeteer --version \
&& echo -e '\n\n' \
) | tee -a /VERSION.txt
######### Build Dependencies ####################################
# Install ArchiveBox Python dependencies
WORKDIR "$CODE_DIR"
COPY --chown=root:root --chmod=755 "./pyproject.toml" "requirements.txt" "$CODE_DIR"/
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked,id=apt-$TARGETARCH$TARGETVARIANT --mount=type=cache,target=/root/.cache/pip,sharing=locked,id=pip-$TARGETARCH$TARGETVARIANT \
echo "[+] Installing PIP ArchiveBox dependencies from requirements.txt for ${TARGETPLATFORM}..." \
&& apt-get update -qq \
&& apt-get install -qq -y -t bookworm-backports \
build-essential gcc \
libssl-dev libldap2-dev libsasl2-dev \
python3-ldap python3-msgpack python3-mutagen python3-regex python3-pycryptodome procps \
pipx \
# && ln -s "$GLOBAL_VENV" "$APP_VENV" \
# && pdm use --venv in-project \
# && pdm run python -m ensurepip \
# && pdm sync --fail-fast --no-editable --group :all --no-self \
# && pdm export -o requirements.txt --without-hashes \
# && source $GLOBAL_VENV/bin/activate \
&& pip install -r requirements.txt \
&& apt-get purge -y \
build-essential gcc \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Install ArchiveBox Python package from source
COPY --chown=root:root --chmod=755 "." "$CODE_DIR/"
RUN --mount=type=cache,target=/root/.cache/pip,sharing=locked,id=pip-$TARGETARCH$TARGETVARIANT \
echo "[*] Installing PIP ArchiveBox package from $CODE_DIR..." \
&& pip install -e "${CODE_DIR}[all]" \
&& rm -rf /var/lib/apt/lists/*
####################################################
# Setup ArchiveBox runtime config
WORKDIR "$DATA_DIR"
RUN openssl rand -hex 16 > /etc/machine-id \
&& mkdir -p "/tmp/archivebox" \
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "/tmp/archivebox" \
&& mkdir -p "/usr/share/archivebox/lib" \
&& chown -R "$DEFAULT_PUID:$DEFAULT_PGID" "/usr/share/archivebox/lib"
ENV GOOGLE_API_KEY=no \
GOOGLE_DEFAULT_CLIENT_ID=no \
GOOGLE_DEFAULT_CLIENT_SECRET=no \
TMP_DIR=/tmp/archivebox \
LIB_DIR=/usr/share/archivebox/lib \
ALLOWED_HOSTS=*
# Print version for nice docker finish summary
RUN (echo -e "\n\n[√] Finished Docker build succesfully. Saving build summary in: /VERSION.txt" \
&& echo -e "PLATFORM=${TARGETPLATFORM} ARCH=$(uname -m) ($(uname -s) ${TARGETARCH} ${TARGETVARIANT})\n" \
&& echo -e "BUILD_END_TIME=$(date +"%Y-%m-%d %H:%M:%S %s")\n\n" \
) | tee -a /VERSION.txt
RUN "$CODE_DIR"/bin/docker_entrypoint.sh version 2>&1 | tee -a /VERSION.txt
####################################################
# Open up the interfaces to the outside world
WORKDIR "$DATA_DIR"
VOLUME "$DATA_DIR"
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=20s --retries=15 \
CMD curl --fail --silent --show-error --max-time 5 --connect-timeout 2 'http://admin.archivebox.localhost:8000/health/' | grep -q 'OK'
CMD curl --silent 'http://localhost:8000/health/' | grep -q 'OK'
ENTRYPOINT ["dumb-init", "--", "/app/bin/docker_entrypoint.sh"]
CMD ["archivebox", "server", "--init", "0.0.0.0:8000"]
CMD ["archivebox", "server", "--quick-init", "0.0.0.0:8000"]
+21
View File
@@ -0,0 +1,21 @@
# Example: Using ArchiveBox in your own project
FROM python:3.12-slim
WORKDIR /app
RUN pip install archivebox==0.8.5rc50
RUN archivebox install
RUN useradd -ms /bin/bash archivebox && chown -R archivebox /app
+1 -1
View File
@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2026 Nick Sweeting
Copyright (c) 2024 Nick Sweeting
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
+341 -266
View File
File diff suppressed because it is too large. Load diff
@@ -45,7 +45,7 @@
### Crawls App
- Archive an entire website -> [Crawl page]
- What are the starting URLs?
- What are the seed URLs?
- How many hops to follow?
- Follow links to external domains?
- Follow links to parent URLs?
@@ -169,4 +169,4 @@ class AlertDestination(models.Model):
maximum_frequency
filter_rules
credentials
alert_template: JINJA2 json/text template that gets populated with alert contents
alert_template: JINJA2 json/text template that gets populated with alert contents
+68 -69
View File
@@ -9,23 +9,12 @@
# in a universe that seems indifferent to us."
# --Norber Weiner
__package__ = "archivebox"
__package__ = 'archivebox'
import os
import sys
from pathlib import Path
from typing import Protocol, cast
class _ReconfigurableStream(Protocol):
def reconfigure(self, *, line_buffering: bool) -> object: ...
# Force unbuffered output for real-time logs
if hasattr(sys.stdout, "reconfigure"):
cast(_ReconfigurableStream, sys.stdout).reconfigure(line_buffering=True)
cast(_ReconfigurableStream, sys.stderr).reconfigure(line_buffering=True)
os.environ["PYTHONUNBUFFERED"] = "1"
from typing import cast
ASCII_LOGO = """
█████╗ ██████╗ ██████╗██╗ ██╗██╗██╗ ██╗███████╗ ██████╗ ██████╗ ██╗ ██╗
@@ -36,73 +25,83 @@ ASCII_LOGO = """
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝╚═╝ ╚═══╝ ╚══════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═╝
"""
# make sure PACKAGE_DIR is in sys.path so we can import all subfolders
# without necessarily waiting for django to load them thorugh INSTALLED_APPS
PACKAGE_DIR = Path(__file__).resolve().parent
os.environ["DJANGO_SETTINGS_MODULE"] = "archivebox.core.settings"
os.environ["TZ"] = "UTC"
if str(PACKAGE_DIR) not in sys.path:
sys.path.append(str(PACKAGE_DIR))
os.environ['DJANGO_SETTINGS_MODULE'] = 'core.settings'
os.environ['TZ'] = 'UTC'
# detect ArchiveBox user's UID/GID based on data dir ownership
from .config.permissions import drop_privileges # noqa
from .config.permissions import drop_privileges # noqa
drop_privileges()
from .misc.checks import check_not_root, check_io_encoding # noqa
from .misc.checks import check_not_root, check_io_encoding # noqa
check_not_root()
check_io_encoding()
from .config.version import VERSION # noqa
# print('INSTALLING MONKEY PATCHES')
from .monkey_patches import * # noqa
# print('DONE INSTALLING MONKEY PATCHES')
# print('LOADING VENDORED LIBRARIES')
from .pkgs import load_vendored_pkgs # noqa
load_vendored_pkgs()
# print('DONE LOADING VENDORED LIBRARIES')
# Load ABX Plugin Specifications + Default Implementations
import abx # noqa
import abx_spec_archivebox # noqa
import abx_spec_config # noqa
import abx_spec_pydantic_pkgr # noqa
import abx_spec_django # noqa
import abx_spec_searchbackend # noqa
abx.pm.add_hookspecs(abx_spec_config.PLUGIN_SPEC)
abx.pm.register(abx_spec_config.PLUGIN_SPEC())
abx.pm.add_hookspecs(abx_spec_pydantic_pkgr.PLUGIN_SPEC)
abx.pm.register(abx_spec_pydantic_pkgr.PLUGIN_SPEC())
abx.pm.add_hookspecs(abx_spec_django.PLUGIN_SPEC)
abx.pm.register(abx_spec_django.PLUGIN_SPEC())
abx.pm.add_hookspecs(abx_spec_searchbackend.PLUGIN_SPEC)
abx.pm.register(abx_spec_searchbackend.PLUGIN_SPEC())
# Cast to ArchiveBoxPluginSpec to enable static type checking of pm.hook.call() methods
abx.pm = cast(abx.ABXPluginManager[abx_spec_archivebox.ArchiveBoxPluginSpec], abx.pm)
pm = abx.pm
# Load all pip-installed ABX-compatible plugins
ABX_ECOSYSTEM_PLUGINS = abx.get_pip_installed_plugins(group='abx')
# Load all built-in ArchiveBox plugins
ARCHIVEBOX_BUILTIN_PLUGINS = {
'config': PACKAGE_DIR / 'config',
'core': PACKAGE_DIR / 'core',
# 'search': PACKAGE_DIR / 'search',
# 'core': PACKAGE_DIR / 'core',
}
# Load all user-defined ArchiveBox plugins
USER_PLUGINS = abx.find_plugins_in_dir(Path(os.getcwd()) / 'user_plugins')
# Import all plugins and register them with ABX Plugin Manager
ALL_PLUGINS = {**ABX_ECOSYSTEM_PLUGINS, **ARCHIVEBOX_BUILTIN_PLUGINS, **USER_PLUGINS}
LOADED_PLUGINS = abx.load_plugins(ALL_PLUGINS)
# Setup basic config, constants, paths, and version
from .config.constants import CONSTANTS # noqa
from .config.paths import PACKAGE_DIR, DATA_DIR, ARCHIVE_DIR # noqa
from .config.version import VERSION # noqa
__version__ = VERSION
__author__ = "ArchiveBox"
__license__ = "MIT"
def __getattr__(name: str):
if name == "CONSTANTS":
from .config.constants import CONSTANTS
os.environ.setdefault("MACHINE_ID", CONSTANTS.MACHINE_ID)
return CONSTANTS
if name == "DATA_DIR":
from .config.paths import DATA_DIR
return DATA_DIR
if name == "VERSION":
return VERSION
if name in ("BUILTIN_PLUGINS_DIR", "USER_PLUGINS_DIR", "ALL_PLUGINS", "LOADED_PLUGINS"):
from abx_plugins import get_plugins_dir
from .config.constants import CONSTANTS
builtin_plugins_dir = Path(get_plugins_dir()).resolve()
user_plugins_dir = CONSTANTS.USER_PLUGINS_DIR
plugins = {
"builtin": builtin_plugins_dir,
"user": user_plugins_dir,
}
values = {
"BUILTIN_PLUGINS_DIR": builtin_plugins_dir,
"USER_PLUGINS_DIR": user_plugins_dir,
"ALL_PLUGINS": plugins,
"LOADED_PLUGINS": plugins,
}
return values[name]
raise AttributeError(name)
__all__ = (
"ASCII_LOGO",
"ASCII_ICON",
"PACKAGE_DIR",
"DATA_DIR",
"CONSTANTS",
"VERSION",
"BUILTIN_PLUGINS_DIR",
"USER_PLUGINS_DIR",
"ALL_PLUGINS",
"LOADED_PLUGINS",
)
__author__ = 'ArchiveBox'
__license__ = 'MIT'
ASCII_ICON = """
██████████████████████████████████████████████████████████████████████████████████████████████████
+7 -8
View File
@@ -1,20 +1,19 @@
#!/usr/bin/env python3
"""This is the entrypoint for python -m archivebox ..."""
"""This is the main entry point for the ArchiveBox CLI."""
__package__ = 'archivebox'
__package__ = "archivebox"
import archivebox # noqa # make sure monkey patches are applied before anything else
import archivebox # noqa # make sure monkey patches are applied before anything else
import sys
from .cli import main
ASCII_LOGO_MINI = r"""
_ _ _ ____
_ _ _ ____
/ \ _ __ ___| |__ (_)_ _____| __ ) _____ __
/ _ \ | '__/ __| '_ \| \ \ / / _ \ _ \ / _ \ \/ /
/ ___ \| | | (__| | | | |\ V / __/ |_) | (_) > <
/ ___ \| | | (__| | | | |\ V / __/ |_) | (_) > <
/_/ \_\_| \___|_| |_|_| \_/ \___|____/ \___/_/\_\
"""
if __name__ == "__main__":
main(args=sys.argv[1:])
if __name__ == '__main__':
main(args=sys.argv[1:], stdin=sys.stdin)
+1
View File
@@ -0,0 +1 @@
__package__ = 'abid_utils'
+223
View File
@@ -0,0 +1,223 @@
__package__ = 'archivebox.abid_utils'
from typing import NamedTuple, Any, Union, Dict
import ulid
import uuid6
import hashlib
from urllib.parse import urlparse
from uuid import UUID
from typeid import TypeID # type: ignore[import-untyped]
from datetime import datetime
from archivebox.misc.util import enforce_types
ABID_PREFIX_LEN = 4
ABID_SUFFIX_LEN = 26
ABID_LEN = 30
ABID_TS_LEN = 10
ABID_URI_LEN = 8
ABID_SUBTYPE_LEN = 2
ABID_RAND_LEN = 6
DEFAULT_ABID_PREFIX = 'obj_'
# allows people to keep their uris secret on a per-instance basis by changing the salt.
# the default means everyone can share the same namespace for URI hashes,
# meaning anyone who has a URI and wants to check if you have it can guess the ABID
DEFAULT_ABID_URI_SALT = '687c2fff14e3a7780faa5a40c237b19b5b51b089'
class ABID(NamedTuple):
"""
e.g. ABID('obj_01HX9FPYTRE4A5CCD901ZYEBQE')
"""
prefix: str # e.g. obj_
ts: str # e.g. 01HX9FPYTR
uri: str # e.g. E4A5CCD9
subtype: str # e.g. 01
rand: str # e.g. ZYEBQE
# salt: str = DEFAULT_ABID_URI_SALT
def __getattr__(self, attr: str) -> Any:
return getattr(self.ulid, attr)
def __eq__(self, other: Any) -> bool:
try:
return self.ulid == other.ulid
except AttributeError:
return NotImplemented
def __str__(self) -> str:
return self.prefix + self.suffix
def __len__(self) -> int:
return len(self.prefix + self.suffix)
@classmethod
def parse(cls, buffer: Union[str, UUID, ulid.ULID, TypeID, 'ABID'], prefix=DEFAULT_ABID_PREFIX) -> 'ABID':
assert buffer, f'Attempted to create ABID from null value {buffer}'
buffer = str(buffer)
if '_' in buffer:
prefix, suffix = buffer.split('_')
else:
prefix, suffix = prefix.strip('_'), buffer
assert len(prefix) == ABID_PREFIX_LEN - 1 # length without trailing _
assert len(suffix) == ABID_SUFFIX_LEN, f'Suffix {suffix} from {buffer} was not {ABID_SUFFIX_LEN} chars long'
return cls(
prefix=abid_part_from_prefix(prefix),
ts=suffix[0:10].upper(),
uri=suffix[10:18].upper(),
subtype=suffix[18:20].upper(),
rand=suffix[20:26].upper(),
)
@property
def uri_salt(self) -> str:
return DEFAULT_ABID_URI_SALT
@property
def suffix(self):
return ''.join((self.ts, self.uri, self.subtype, self.rand))
@property
def ulid(self) -> ulid.ULID:
return ulid.parse(self.suffix)
@property
def uuid(self) -> UUID:
return self.ulid.uuid
@property
def uuid6(self) -> uuid6.UUID:
return uuid6.UUID(hex=self.uuid.hex)
@property
def typeid(self) -> TypeID:
return TypeID.from_uuid(prefix=self.prefix.strip('_'), suffix=self.uuid6)
@property
def datetime(self) -> datetime:
return self.ulid.timestamp().datetime
####################################################
@enforce_types
def uri_hash(uri: Union[str, bytes], salt: str=DEFAULT_ABID_URI_SALT) -> str:
"""
https://example.com -> 'E4A5CCD9AF4ED2A6E0954DF19FD274E9CDDB4853051F033FD518BFC90AA1AC25' (example.com)
"""
if isinstance(uri, bytes):
uri_str: str = uri.decode()
else:
uri_str = str(uri)
# only hash the domain part of URLs
if '://' in uri_str:
try:
domain = urlparse(uri_str).netloc
if domain:
uri_str = domain
except AttributeError:
pass
# the uri hash is the sha256 of the domain + salt
uri_bytes = uri_str.encode('utf-8') + salt.encode('utf-8')
return hashlib.sha256(uri_bytes).hexdigest().upper()
@enforce_types
def abid_part_from_prefix(prefix: str) -> str:
"""
'snp_'
"""
# if prefix is None:
# return 'obj_'
prefix = prefix.strip('_').lower()
assert len(prefix) == 3
return prefix + '_'
@enforce_types
def abid_part_from_uri(uri: Any, salt: str=DEFAULT_ABID_URI_SALT) -> str:
"""
'E4A5CCD9' # takes first 8 characters of sha256(url)
"""
uri = str(uri).strip()
assert uri not in ('None', '')
return uri_hash(uri, salt=salt)[:ABID_URI_LEN]
@enforce_types
def abid_part_from_ts(ts: datetime) -> str:
"""
'01HX9FPYTR' # produces 10 character Timestamp section of ulid based on added date
"""
return str(ulid.from_timestamp(ts))[:ABID_TS_LEN]
@enforce_types
def ts_from_abid(abid: str) -> datetime:
return ulid.parse(abid.split('_', 1)[-1]).timestamp().datetime
@enforce_types
def abid_part_from_subtype(subtype: str | int) -> str:
"""
Snapshots have 01 type, other objects have other subtypes like wget/media/etc.
Also allows us to change the ulid spec later by putting special sigil values here.
"""
subtype = str(subtype)
if len(subtype) == ABID_SUBTYPE_LEN:
return subtype
return hashlib.sha256(subtype.encode('utf-8')).hexdigest()[:ABID_SUBTYPE_LEN].upper()
@enforce_types
def abid_part_from_rand(rand: Union[str, UUID, None, int]) -> str:
"""
'ZYEBQE' # takes last 6 characters of randomness from existing legacy uuid db field
"""
if rand is None:
# if it's None we generate a new random 6 character hex string
return str(ulid.new())[-ABID_RAND_LEN:]
elif isinstance(rand, UUID):
# if it's a uuid we take the last 6 characters of the ULID represation of it
return str(ulid.from_uuid(rand))[-ABID_RAND_LEN:]
elif isinstance(rand, int):
# if it's a BigAutoInteger field we convert it from an int to a 0-padded string
rand_str = str(rand)[-ABID_RAND_LEN:]
padding_needed = ABID_RAND_LEN - len(rand_str)
rand_str = ('0'*padding_needed) + rand_str
return rand_str
# otherwise treat it as a string, take the last 6 characters of it verbatim
return str(rand)[-ABID_RAND_LEN:].upper()
@enforce_types
def abid_hashes_from_values(prefix: str, ts: datetime, uri: Any, subtype: str | int, rand: Union[str, UUID, None, int], salt: str=DEFAULT_ABID_URI_SALT) -> Dict[str, str]:
return {
'prefix': abid_part_from_prefix(prefix),
'ts': abid_part_from_ts(ts),
'uri': abid_part_from_uri(uri, salt=salt),
'subtype': abid_part_from_subtype(subtype),
'rand': abid_part_from_rand(rand),
# 'salt': don't add this, salt combined with uri above to form a single hash
}
@enforce_types
def abid_from_values(prefix: str, ts: datetime, uri: str, subtype: str, rand: Union[str, UUID, None, int], salt: str=DEFAULT_ABID_URI_SALT) -> ABID:
"""
Return a freshly derived ABID (assembled from attrs defined in ABIDModel.abid_*_src).
"""
abid = ABID(**abid_hashes_from_values(prefix, ts, uri, subtype, rand, salt=salt))
assert abid.ulid and abid.uuid and abid.typeid, f'Failed to calculate {prefix}_ABID for ts={ts} uri={uri} subtyp={subtype} rand={rand}'
return abid
+174
View File
@@ -0,0 +1,174 @@
__package__ = 'archivebox.abid_utils'
from typing import Any
from django.contrib import admin, messages
from django.core.exceptions import ValidationError
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.shortcuts import redirect
from django_object_actions import DjangoObjectActions, action
from archivebox.misc.util import parse_date
from .abid import ABID
def highlight_diff(display_val: Any, compare_val: Any, invert: bool=False, color_same: str | None=None, color_diff: str | None=None):
"""highlight each character in red that differs with the char at the same index in compare_val"""
display_val = str(display_val)
compare_val = str(compare_val)
if len(compare_val) < len(display_val):
compare_val += ' ' * (len(display_val) - len(compare_val))
similar_color, highlighted_color = color_same or 'inherit', color_diff or 'red'
if invert:
similar_color, highlighted_color = color_same or 'green', color_diff or 'inherit'
return mark_safe(''.join(
format_html('<span style="color: {};">{}</span>', highlighted_color, display_val[i])
if display_val[i] != compare_val[i] else
format_html('<span style="color: {};">{}</span>', similar_color, display_val[i])
for i in range(len(display_val))
))
def get_abid_info(self, obj, request=None):
from archivebox.api.auth import get_or_create_api_token
try:
#abid_diff = f' != obj.ABID: {highlight_diff(obj.ABID, obj.abid)} ❌' if str(obj.ABID) != str(obj.abid) else ' == .ABID ✅'
fresh_values = obj.ABID_FRESH_VALUES
fresh_hashes = obj.ABID_FRESH_HASHES
fresh_diffs = obj.ABID_FRESH_DIFFS
fresh_abid = ABID(**fresh_hashes)
fresh_abid_diff = f'❌ != &nbsp; .fresh_abid: {highlight_diff(fresh_abid, obj.ABID)}' if str(fresh_abid) != str(obj.ABID) else '✅'
fresh_uuid_diff = f'❌ != &nbsp; .fresh_uuid: {highlight_diff(fresh_abid.uuid, obj.ABID.uuid)}' if str(fresh_abid.uuid) != str(obj.ABID.uuid) else '✅'
id_pk_diff = f'❌ != .pk: {highlight_diff(obj.pk, obj.id)}' if str(obj.pk) != str(obj.id) else '✅'
fresh_ts = parse_date(fresh_values['ts']) or None
ts_diff = f'❌ != {highlight_diff( fresh_hashes["ts"], obj.ABID.ts)}' if fresh_hashes["ts"] != obj.ABID.ts else '✅'
derived_uri = fresh_hashes['uri']
uri_diff = f'❌ != {highlight_diff(derived_uri, obj.ABID.uri)}' if derived_uri != obj.ABID.uri else '✅'
derived_subtype = fresh_hashes['subtype']
subtype_diff = f'❌ != {highlight_diff(derived_subtype, obj.ABID.subtype)}' if derived_subtype != obj.ABID.subtype else '✅'
derived_rand = fresh_hashes['rand']
rand_diff = f'❌ != {highlight_diff(derived_rand, obj.ABID.rand)}' if derived_rand != obj.ABID.rand else '✅'
return format_html(
# URL Hash: <code style="font-size: 10px; user-select: all">{}</code><br/>
'''
<a href="{}" style="font-size: 16px; font-family: monospace; user-select: all; border-radius: 8px; background-color: #ddf; padding: 3px 5px; border: 1px solid #aaa; margin-bottom: 8px; display: inline-block; vertical-align: top;">{}</a> &nbsp; &nbsp; <a href="{}" style="color: limegreen; font-size: 0.9em; vertical-align: 1px; font-family: monospace;">📖 API DOCS</a>
<br/><hr/>
<div style="opacity: 0.8">
&nbsp; &nbsp; <small style="opacity: 0.8">.id: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;<code style="font-size: 10px; user-select: all">{}</code> &nbsp; &nbsp; {}</small><br/>
&nbsp; &nbsp; <small style="opacity: 0.8">.abid.uuid: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <code style="font-size: 10px; user-select: all">{}</code> &nbsp; &nbsp; {}</small><br/>
&nbsp; &nbsp; <small style="opacity: 0.8">.abid: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <code style="font-size: 10px; user-select: all">{}</code> &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {}</small><br/>
<hr/>
&nbsp; &nbsp; TS: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;<code style="font-size: 10px;"><b style="user-select: all">{}</b> &nbsp; {}</code> &nbsp; &nbsp; &nbsp;&nbsp; <code style="font-size: 10px;"><b>{}</b></code> {}: <code style="user-select: all">{}</code><br/>
&nbsp; &nbsp; URI: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <code style="font-size: 10px;"><b style="user-select: all">{}</b> &nbsp; &nbsp; {}</code> &nbsp;&nbsp; &nbsp; &nbsp; &nbsp;&nbsp; <code style="font-size: 10px;"><b>{}</b></code> <span style="display:inline-block; vertical-align: -4px; width: 330px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">{}: <code style="user-select: all">{}</code></span><br/>
&nbsp; &nbsp; SUBTYPE: &nbsp; &nbsp; &nbsp; <code style="font-size: 10px;"><b style="user-select: all">{}</b> &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; {}</code> &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <code style="font-size: 10px;"><b>{}</b></code> {}: <code style="user-select: all">{}</code><br/>
&nbsp; &nbsp; RAND: &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <code style="font-size: 10px;"><b style="user-select: all">{}</b> &nbsp; &nbsp; &nbsp; {}</code> &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; <code style="font-size: 10px;"><b>{}</b></code> {}: <code style="user-select: all">{}</code></code>
<br/><hr/>
<span style="color: #f375a0">{}</span> <code style="color: red"><b>{}</b></code> {}
</div>
''',
obj.api_url + (f'?api_key={get_or_create_api_token(request.user)}' if request and request.user else ''), obj.api_url, obj.api_docs_url,
highlight_diff(obj.id, obj.ABID.uuid, invert=True), mark_safe(id_pk_diff),
highlight_diff(obj.ABID.uuid, obj.id, invert=True), mark_safe(fresh_uuid_diff),
highlight_diff(obj.abid, fresh_abid), mark_safe(fresh_abid_diff),
# str(fresh_abid.uuid), mark_safe(fresh_uuid_diff),
# str(fresh_abid), mark_safe(fresh_abid_diff),
highlight_diff(obj.ABID.ts, fresh_hashes['ts']), highlight_diff(str(obj.ABID.uuid)[0:14], str(fresh_abid.uuid)[0:14]), mark_safe(ts_diff), obj.abid_ts_src, fresh_ts and fresh_ts.isoformat(),
highlight_diff(obj.ABID.uri, derived_uri), highlight_diff(str(obj.ABID.uuid)[14:26], str(fresh_abid.uuid)[14:26]), mark_safe(uri_diff), obj.abid_uri_src, str(fresh_values['uri']),
highlight_diff(obj.ABID.subtype, derived_subtype), highlight_diff(str(obj.ABID.uuid)[26:28], str(fresh_abid.uuid)[26:28]), mark_safe(subtype_diff), obj.abid_subtype_src, str(fresh_values['subtype']),
highlight_diff(obj.ABID.rand, derived_rand), highlight_diff(str(obj.ABID.uuid)[28:36], str(fresh_abid.uuid)[28:36]), mark_safe(rand_diff), obj.abid_rand_src, str(fresh_values['rand'])[-7:],
'Some values the ABID depends on have changed since the ABID was issued:' if fresh_diffs else '',
", ".join(diff['abid_src'] for diff in fresh_diffs.values()),
'(clicking "Regenerate ABID" in the upper right will assign a new ABID, breaking any external references to the old ABID)' if fresh_diffs else '',
)
except Exception as e:
# import ipdb; ipdb.set_trace()
return str(e)
class ABIDModelAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = ('created_at', 'created_by', 'abid')
sort_fields = ('created_at', 'created_by', 'abid')
readonly_fields = ('created_at', 'modified_at', 'abid_info')
# fields = [*readonly_fields]
change_actions = ("regenerate_abid",)
# changelist_actions = ("regenerate_abid",)
def _get_obj_does_not_exist_redirect(self, request, opts, object_id):
try:
object_pk = self.model.id_from_abid(object_id)
return redirect(self.request.path.replace(object_id, object_pk), permanent=False)
except (self.model.DoesNotExist, ValidationError):
pass
return super()._get_obj_does_not_exist_redirect(request, opts, object_id) # type: ignore
def queryset(self, request):
self.request = request
return super().queryset(request) # type: ignore
def change_view(self, request, object_id, form_url="", extra_context=None):
self.request = request
return super().change_view(request, object_id, form_url, extra_context)
def get_form(self, request, obj=None, **kwargs):
self.request = request
form = super().get_form(request, obj, **kwargs)
if 'created_by' in form.base_fields:
form.base_fields['created_by'].initial = request.user
if obj:
if obj.ABID_FRESH_DIFFS:
messages.warning(request, "The ABID is not in sync with the object! See the API Identifiers section below for more info...")
return form
def get_formset(self, request, formset=None, obj=None, **kwargs):
formset = super().get_formset(request, formset, obj, **kwargs) # type: ignore
formset.form.base_fields['created_at'].disabled = True
return formset
def save_model(self, request, obj, form, change):
self.request = request
old_abid = getattr(obj, '_previous_abid', None) or obj.abid
super().save_model(request, obj, form, change)
obj.refresh_from_db()
new_abid = obj.abid
if new_abid != old_abid:
messages.warning(request, f"The object's ABID has been updated! {old_abid} -> {new_abid} (any external references to the old ABID will need to be updated manually)")
# import ipdb; ipdb.set_trace()
@admin.display(description='API Identifiers')
def abid_info(self, obj):
return get_abid_info(self, obj, request=self.request)
@action(label="Regenerate ABID", description="Re-Generate the ABID based on fresh values")
def regenerate_abid(self, request, obj):
old_abid = str(obj.abid)
obj.abid = obj.issue_new_abid(overwrite=True)
obj.save()
obj.refresh_from_db()
new_abid = str(obj.abid)
if new_abid != old_abid:
messages.warning(request, f"The object's ABID has been updated! {old_abid} -> {new_abid} (any external references to the old ABID will need to be updated manually)")
else:
messages.success(request, "The ABID was not regenerated, it is already up-to-date with the object.")
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class AbidUtilsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'abid_utils'
+509
View File
@@ -0,0 +1,509 @@
"""
This file provides the Django ABIDField and ABIDModel base model to inherit from.
"""
from typing import Any, Dict, Union, List, Set, cast
from uuid import uuid4
from functools import partial
from charidfield import CharIDField # type: ignore[import-untyped]
from django.contrib import admin
from django.core.exceptions import ValidationError, NON_FIELD_ERRORS
from django.db import models
from django.utils import timezone
from django.db.utils import OperationalError
from django.contrib.auth import get_user_model
from django.urls import reverse_lazy
from django_stubs_ext.db.models import TypedModelMeta
from .abid import (
ABID,
ABID_LEN,
ABID_RAND_LEN,
ABID_SUFFIX_LEN,
DEFAULT_ABID_PREFIX,
DEFAULT_ABID_URI_SALT,
abid_part_from_prefix,
abid_hashes_from_values,
ts_from_abid,
abid_part_from_ts,
)
####################################################
# Database Field for typeid/ulid style IDs with a prefix, e.g. snp_01BJQMF54D093DXEAWZ6JYRPAQ
ABIDField = partial(
CharIDField,
max_length=ABID_LEN,
help_text="ABID-format identifier for this entity (e.g. snp_01BJQMF54D093DXEAWZ6JYRPAQ)",
default=None,
null=True,
blank=True,
db_index=True,
unique=True,
)
def get_or_create_system_user_pk(username='system'):
"""Get or create a system user with is_superuser=True to be the default owner for new DB rows"""
User = get_user_model()
# if only one user exists total, return that user
if User.objects.filter(is_superuser=True).count() == 1:
return User.objects.filter(is_superuser=True).values_list('pk', flat=True)[0]
# otherwise, create a dedicated "system" user
user, _was_created = User.objects.get_or_create(username=username, is_staff=True, is_superuser=True, defaults={'email': '', 'password': ''})
return user.pk
class AutoDateTimeField(models.DateTimeField):
# def pre_save(self, model_instance, add):
# return timezone.now()
pass
class ABIDError(Exception):
pass
class ABIDModel(models.Model):
"""
Abstract Base Model for other models to depend on. Provides ArchiveBox ID (ABID) interface.
"""
abid_prefix: str = DEFAULT_ABID_PREFIX # e.g. 'tag_'
abid_ts_src = 'self.created_at' # e.g. 'self.created_at'
abid_uri_src = 'None' # e.g. 'self.uri' (MUST BE SET)
abid_subtype_src = 'self.__class__.__name__' # e.g. 'self.extractor'
abid_rand_src = 'self.id' # e.g. 'self.uuid' or 'self.id'
abid_salt: str = DEFAULT_ABID_URI_SALT # combined with self.uri to anonymize hashes on a per-install basis (default is shared globally with all users, means everyone will hash ABC to -> 123 the same around the world, makes it easy to share ABIDs across installs and see if they are for the same URI. Change this if you dont want your hashes to be guessable / in the same hash space as all other users)
abid_drift_allowed: bool = False # set to True to allow abid_field values to change after a fixed ABID has been issued (NOT RECOMMENDED: means values can drift out of sync from original ABID)
# id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID')
# abid = ABIDField(prefix=abid_prefix)
# created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=None, null=False)
# created_at = AutoDateTimeField(default=None, null=False, db_index=True)
# modified_at = models.DateTimeField(auto_now=True)
_prefetched_objects_cache: Dict[str, Any]
class Meta(TypedModelMeta):
abstract = True
@admin.display(description='Summary')
def __str__(self) -> str:
return f'[{self.abid or (self.abid_prefix + "NEW")}] {self.__class__.__name__} {eval(self.abid_uri_src)}'
def __init__(self, *args: Any, **kwargs: Any) -> None:
"""Overriden __init__ method ensures we have a stable creation timestamp that fields can use within initialization code pre-saving to DB."""
super().__init__(*args, **kwargs)
# pre-compute a stable timestamp of the obj init time (with abid.ts precision limit applied) for use when object is first created,
# some other fields depend on a timestamp at creation time, and it's nice to have one common timestamp they can all share.
# Used as an alternative to auto_now_add=True + auto_now=True which can produce two different times & requires saving to DB to get the TS.
# (ordinarily fields cant depend on other fields until the obj is saved to db and recalled)
self._init_timestamp = ts_from_abid(abid_part_from_ts(timezone.now()))
def clean(self, abid_drift_allowed: bool | None=None) -> None:
if self._state.adding:
# only runs once when a new object is first saved to the DB
# sets self.id, self.pk, self.created_by, self.created_at, self.modified_at
self._previous_abid = None
self.abid = str(self.issue_new_abid())
else:
# otherwise if updating, make sure none of the field changes would invalidate existing ABID
abid_diffs = self.ABID_FRESH_DIFFS
if abid_diffs:
# change has invalidated the existing ABID, raise a nice ValidationError pointing out which fields caused the issue
keys_changed = ', '.join(diff['abid_src'] for diff in abid_diffs.values())
full_summary = (
f"This {self.__class__.__name__}(abid={str(self.ABID)}) was assigned a fixed, unique ID (ABID) based on its contents when it was created. " +
f"\nYou must reduce your changes to not affect these fields [{keys_changed}], or create a new {self.__class__.__name__} object instead."
)
change_error = ValidationError({
**{
# url: ValidationError('Cannot update self.url= https://example.com/old -> https://example.com/new ...')
diff['abid_src'].replace('self.', '')
if (diff['old_val'] != diff['new_val']) and hasattr(self, diff['abid_src'].replace('self.', ''))
else NON_FIELD_ERRORS
: ValidationError(
'Cannot update %(abid_src)s= "%(old_val)s" -> "%(new_val)s" (would alter %(model)s.ABID.%(key)s=%(old_hash)s to %(new_hash)s)',
code='ABIDConflict',
params=diff,
)
for diff in abid_diffs.values()
},
NON_FIELD_ERRORS: ValidationError(full_summary),
})
allowed_to_invalidate_abid = self.abid_drift_allowed if (abid_drift_allowed is None) else abid_drift_allowed
if allowed_to_invalidate_abid:
# print(f'\n#### WARNING: Change allowed despite it invalidating the ABID of an existing record ({self.__class__.__name__}.abid_drift_allowed={self.abid_drift_allowed})!', self.abid)
# print(change_error)
# print('--------------------------------------------------------------------------------------------------')
pass
else:
print(f'\n#### ERROR: Change blocked because it would invalidate ABID of an existing record ({self.__class__.__name__}.abid_drift_allowed={self.abid_drift_allowed})', self.abid)
print(change_error)
print('--------------------------------------------------------------------------------------------------')
raise change_error
def save(self, *args: Any, abid_drift_allowed: bool | None=None, **kwargs: Any) -> None:
"""Overriden save method ensures new ABID is generated while a new object is first saving."""
self.clean(abid_drift_allowed=abid_drift_allowed)
return super().save(*args, **kwargs)
@classmethod
def id_from_abid(cls, abid: str) -> str:
return str(cls.objects.only('pk').get(abid=cls.abid_prefix + str(abid).split('_', 1)[-1]).pk)
@property
def ABID_SOURCES(self) -> Dict[str, str]:
""""Get the dict of fresh ABID component values based on the live object's properties."""
assert self.abid_prefix
return {
'prefix': 'self.abid_prefix', # defined as static class vars at build time
'ts': self.abid_ts_src,
'uri': self.abid_uri_src,
'subtype': self.abid_subtype_src,
'rand': self.abid_rand_src,
'salt': 'self.abid_salt', # defined as static class vars at build time
}
@property
def ABID_FRESH_VALUES(self) -> Dict[str, Any]:
""""Get the dict of fresh ABID component values based on the live object's properties."""
abid_sources = self.ABID_SOURCES
assert all(src != 'None' for src in abid_sources.values())
return {
'prefix': eval(abid_sources['prefix']),
'ts': eval(abid_sources['ts']),
'uri': eval(abid_sources['uri']),
'subtype': eval(abid_sources['subtype']),
'rand': eval(abid_sources['rand']),
'salt': eval(abid_sources['salt']),
}
@property
def ABID_FRESH_HASHES(self) -> Dict[str, str]:
""""Get the dict of fresh ABID component hashes based on the live object's properties."""
abid_values = self.ABID_FRESH_VALUES
assert all(val for val in abid_values.values())
return abid_hashes_from_values(
prefix=abid_values['prefix'],
ts=abid_values['ts'],
uri=abid_values['uri'],
subtype=abid_values['subtype'],
rand=abid_values['rand'],
salt=abid_values['salt'],
)
@property
def ABID_FRESH_DIFFS(self) -> Dict[str, Dict[str, Any]]:
"""Get the dict of discrepancies between the existing saved ABID and a new fresh ABID computed based on the live object."""
existing_abid = self.ABID
existing_values = {} if self._state.adding else self.__class__.objects.get(pk=self.pk).ABID_FRESH_VALUES
abid_sources = self.ABID_SOURCES
fresh_values = self.ABID_FRESH_VALUES
fresh_hashes = self.ABID_FRESH_HASHES
return {
key: {
'key': key,
'model': self.__class__.__name__,
'pk': self.pk,
'abid_src': abid_sources[key],
'old_val': existing_values.get(key, None),
'old_hash': getattr(existing_abid, key),
'new_val': fresh_values[key],
'new_hash': new_hash,
'summary': f'{abid_sources[key]}= "{existing_values.get(key, None)}" -> "{fresh_values[key]}" (would alter {self.__class__.__name__.lower()}.ABID.{key}={getattr(existing_abid, key)} to {new_hash})',
}
for key, new_hash in fresh_hashes.items()
if getattr(existing_abid, key) != new_hash
}
def issue_new_abid(self, overwrite=False) -> ABID:
"""
Issue a new ABID based on the current object's properties, can only be called once on new objects (before they are saved to DB).
"""
if not overwrite:
assert self._state.adding, 'Can only issue new ABID when model._state.adding is True'
assert eval(self.abid_uri_src), f'Can only issue new ABID if self.abid_uri_src is defined ({self.abid_uri_src}={eval(self.abid_uri_src)})'
# Setup Field defaults to be ready for ABID generation
self.abid = None
self.id = self.id or uuid4()
self.pk = self.id
self.created_at = self.created_at or self._init_timestamp # cut off precision to match precision of TS component
self.modified_at = self.modified_at or self.created_at
self.created_by_id = (hasattr(self, 'created_by_id') and self.created_by_id) or get_or_create_system_user_pk()
# Compute fresh ABID values & hashes based on object's live properties
abid_fresh_values = self.ABID_FRESH_VALUES
assert all(abid_fresh_values.values()), f'All ABID_FRESH_VALUES must be set {abid_fresh_values}'
abid_fresh_hashes = self.ABID_FRESH_HASHES
assert all(abid_fresh_hashes.values()), f'All ABID_FRESH_HASHES must be able to be generated {abid_fresh_hashes}'
new_abid = ABID(**abid_fresh_hashes)
assert new_abid.ulid and new_abid.uuid and new_abid.typeid, f'Failed to calculate {abid_fresh_values["prefix"]}_ABID for {self.__class__.__name__}'
return new_abid
@property
def ABID(self) -> ABID:
"""
Get the object's existing ABID (from self.abid if it's already saved to DB, otherwise generated fresh)
e.g. -> ABID(ts='01HX9FPYTR', uri='E4A5CCD9', subtype='00', rand='ZYEBQE')
"""
if self.abid:
return ABID.parse(cast(str, self.abid))
return self.issue_new_abid()
# These are all example helpers to make it easy to access alternate formats of the ABID.*, only add them if you actually need them
# @property
# def UUID(self) -> UUID:
# """
# Get a uuid.UUID (v4) representation of the object's ABID.
# """
# return self.ABID.uuid
# @property
# def uuid(self) -> str:
# """
# Get a str uuid.UUID (v4) representation of the object's ABID.
# """
# return str(self.ABID.uuid)
# @property
# def ULID(self) -> ULID:
# """
# Get a ulid.ULID representation of the object's ABID.
# """
# return self.ABID.ulid
# @property
# def TypeID(self) -> TypeID:
# """
# Get a typeid.TypeID (stripe-style) representation of the object's ABID.
# """
# return self.ABID.typeid
@property
def api_url(self) -> str:
"""
Compute the REST API URL to access this object.
e.g. /api/v1/core/snapshot/snp_01BJQMF54D093DXEAWZ6JYRP
"""
return reverse_lazy('api-1:get_any', args=[self.abid]) # + f'?api_key={get_or_create_api_token(request.user)}'
@property
def api_docs_url(self) -> str:
"""
Compute the REST API Documentation URL to learn about accessing this object.
e.g. /api/v1/docs#/Core%20Models/api_v1_core_get_snapshots
"""
return f'/api/v1/docs#/{self._meta.app_label.title()}%20Models/api_v1_{self._meta.app_label}_get_{self._meta.db_table}'
@property
def admin_change_url(self) -> str:
return f"/admin/{self._meta.app_label}/{self._meta.model_name}/{self.pk}/change/"
def get_absolute_url(self):
return self.api_docs_url
class ModelWithHealthStats(models.Model):
num_uses_failed = models.PositiveIntegerField(default=0)
num_uses_succeeded = models.PositiveIntegerField(default=0)
class Meta:
abstract = True
def record_health_failure(self) -> None:
self.num_uses_failed += 1
self.save()
def record_health_success(self) -> None:
self.num_uses_succeeded += 1
self.save()
def reset_health(self) -> None:
# move all the failures to successes when resetting so we dont lose track of the total count
self.num_uses_succeeded = self.num_uses_failed + self.num_uses_succeeded
self.num_uses_failed = 0
self.save()
@property
def health(self) -> int:
total_uses = max((self.num_uses_failed + self.num_uses_succeeded, 1))
success_pct = (self.num_uses_succeeded / total_uses) * 100
return round(success_pct)
####################################################
# Django helpers
def find_all_abid_prefixes() -> Dict[str, type[models.Model]]:
"""
Return the mapping of all ABID prefixes to their models.
e.g. {'tag_': core.models.Tag, 'snp_': core.models.Snapshot, ...}
"""
import django.apps
prefix_map = {}
for model in django.apps.apps.get_models():
abid_prefix = getattr(model, 'abid_prefix', None)
if abid_prefix:
prefix_map[abid_prefix] = model
return prefix_map
def find_prefix_for_abid(abid: ABID) -> str:
"""
Find the correct prefix for a given ABID that may have be missing a prefix (slow).
e.g. ABID('obj_01BJQMF54D093DXEAWZ6JYRPAQ') -> 'snp_'
"""
# if existing abid prefix is correct, lookup is easy
model = find_model_from_abid(abid)
if model:
assert issubclass(model, ABIDModel)
return model.abid_prefix
# prefix might be obj_ or missing, fuzzy-search to find any object that matches
return find_obj_from_abid_rand(abid)[0].abid_prefix
def find_model_from_abid_prefix(prefix: str) -> type[ABIDModel] | None:
"""
Return the Django Model that corresponds to a given ABID prefix.
e.g. 'tag_' -> core.models.Tag
"""
prefix = abid_part_from_prefix(prefix)
import django.apps
for model in django.apps.apps.get_models():
if not issubclass(model, ABIDModel): continue # skip non-ABID-enabled models
if not hasattr(model, 'objects'): continue # skip abstract models
if (model.abid_prefix == prefix):
return model
return None
def find_model_from_abid(abid: ABID) -> type[models.Model] | None:
"""
Shortcut for find_model_from_abid_prefix(abid.prefix)
"""
return find_model_from_abid_prefix(abid.prefix)
def find_obj_from_abid_rand(rand: Union[ABID, str], model=None) -> List[ABIDModel]:
"""
Find an object corresponding to an ABID by exhaustively searching using its random suffix (slow).
e.g. 'obj_....................JYRPAQ' -> Snapshot('snp_01BJQMF54D093DXEAWZ6JYRPAQ')
Honestly should only be used for debugging, no reason to expose this ability to users.
"""
# convert str to ABID if necessary
if isinstance(rand, ABID):
abid: ABID = rand
else:
rand = str(rand)
if len(rand) < ABID_SUFFIX_LEN:
padding_needed = ABID_SUFFIX_LEN - len(rand)
rand = ('0'*padding_needed) + rand
abid = ABID.parse(rand)
import django.apps
partial_matches: List[ABIDModel] = []
models_to_try = cast(Set[type[models.Model]], set(filter(bool, (
model,
find_model_from_abid(abid),
*django.apps.apps.get_models(),
))))
# print(abid, abid.rand, abid.uuid, models_to_try)
for model in models_to_try:
if not issubclass(model, ABIDModel): continue # skip Models that arent ABID-enabled
if not hasattr(model, 'objects'): continue # skip abstract Models
assert hasattr(model, 'objects') # force-fix for type hint nit about missing manager https://github.com/typeddjango/django-stubs/issues/1684
# continue on to try fuzzy searching by randomness portion derived from uuid field
try:
qs = []
if hasattr(model, 'abid'):
qs = model.objects.filter(abid__endswith=abid.rand)
elif hasattr(model, 'uuid'):
qs = model.objects.filter(uuid__endswith=str(abid.uuid)[-ABID_RAND_LEN:])
elif hasattr(model, 'id'):
# NOTE: this only works on SQLite where every column is a string
# other DB backends like postgres dont let you do __endswith if this is a BigAutoInteger field
# try to search for uuid=...-2354352
# try to search for id=...2354352
# try to search for id=2354352
qs = model.objects.filter(
models.Q(id__endswith=str(abid.uuid)[-ABID_RAND_LEN:])
| models.Q(id__endswith=abid.rand)
| models.Q(id__startswith=str(int(abid.rand)) if abid.rand.isdigit() else abid.rand)
)
for obj in qs:
if abid in (str(obj.ABID), str(obj.id), str(obj.pk), str(obj.abid)):
# found exact match, no need to keep iterating
return [obj]
partial_matches.append(obj)
except OperationalError as err:
print(f'[!] WARNING: Got error while trying to iterate through QuerySet for {model}:', err, '\n')
return partial_matches
def find_obj_from_abid(abid: ABID, model=None, fuzzy=False) -> Any:
"""
Find an object with a given ABID by filtering possible models for a matching abid/uuid/id (fast).
e.g. 'snp_01BJQMF54D093DXEAWZ6JYRPAQ' -> Snapshot('snp_01BJQMF54D093DXEAWZ6JYRPAQ')
"""
model = model or find_model_from_abid(abid)
assert model, f'Could not find model that could match this ABID type: {abid}'
try:
if hasattr(model, 'abid'):
return model.objects.get(abid__endswith=abid.suffix)
if hasattr(model, 'uuid'):
return model.objects.get(uuid=abid.uuid)
return model.objects.get(id=abid.uuid)
except model.DoesNotExist:
# if the model has an abid field then it shouldve matched, pointless to fuzzy search in that case
if hasattr(model, 'abid') or (not fuzzy):
raise
# continue on to try fuzzy searching by randomness portion derived from uuid field
match_by_rand = find_obj_from_abid_rand(abid, model=model)
if match_by_rand:
if match_by_rand[0].abid_prefix != abid.prefix:
print(f'[!] WARNING: fetched object {match_by_rand} even though prefix {abid.prefix} doesnt match!', abid, '\n')
return match_by_rand
raise model.DoesNotExist
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
File renamed without changes.
+571
View File
@@ -0,0 +1,571 @@
__package__ = 'archivebox.actors'
import os
import time
from typing import ClassVar, Generic, TypeVar, Any, Literal, Type, Iterable, cast, get_args
from datetime import timedelta
from multiprocessing import Process, cpu_count
from threading import Thread, get_native_id
import psutil
from rich import print
from statemachine import State, StateMachine, registry
from statemachine.mixins import MachineMixin
from django import db
from django.db.models import QuerySet, sql, Q
from django.db.models import Model as DjangoModel
from django.utils import timezone
from django.utils.functional import classproperty
from .models import ModelWithStateMachine
# from archivebox.logging_util import TimedProgress
class ActorObjectAlreadyClaimed(Exception):
"""Raised when the Actor tries to claim the next object from the queue but it's already been claimed by another Actor"""
pass
class ActorQueueIsEmpty(Exception):
"""Raised when the Actor tries to get the next object from the queue but it's empty"""
pass
CPU_COUNT = cpu_count()
DEFAULT_MAX_TICK_TIME = 60
DEFAULT_MAX_CONCURRENT_ACTORS = min(max(2, int(CPU_COUNT * 0.6)), 8) # 2 < 60% * num available cpu cores < 8
limit = lambda n, max: min(n, max)
LaunchKwargs = dict[str, Any]
ObjectState = State | str
ObjectStateList = Iterable[ObjectState]
ModelType = TypeVar('ModelType', bound=ModelWithStateMachine)
class ActorType(Generic[ModelType]):
"""
Base class for all actors. Usage:
class FaviconActor(ActorType[FaviconArchiveResult]):
FINAL_STATES: ClassVar[tuple[str, ...]] = ('succeeded', 'failed', 'skipped')
ACTIVE_STATE: ClassVar[str] = 'started'
@classmethod
def qs(cls) -> QuerySet[FaviconArchiveResult]:
return ArchiveResult.objects.filter(extractor='favicon') # or leave the default: FaviconArchiveResult.objects.all()
"""
### Class attributes (defined on the class at compile-time when ActorType[MyModel] is defined)
Model: Type[ModelType]
StateMachineClass: Type[StateMachine]
STATE_FIELD_NAME: ClassVar[str]
ACTIVE_STATE: ClassVar[ObjectState]
FINAL_STATES: ClassVar[ObjectStateList]
EVENT_NAME: ClassVar[str] = 'tick' # the event name to trigger on the obj.sm: StateMachine (usually 'tick')
CLAIM_ORDER: ClassVar[tuple[str, ...]] = ('retry_at',) # the .order(*args) to claim the queue objects in, use ('?',) for random order
CLAIM_FROM_TOP_N: ClassVar[int] = CPU_COUNT * 10 # the number of objects to consider when atomically getting the next object from the queue
CLAIM_ATOMIC: ClassVar[bool] = True # whether to atomically fetch+claim the next object in one query, or fetch and lock it in two queries
MAX_TICK_TIME: ClassVar[int] = DEFAULT_MAX_TICK_TIME # maximum duration in seconds to process a single object
MAX_CONCURRENT_ACTORS: ClassVar[int] = DEFAULT_MAX_CONCURRENT_ACTORS # maximum number of concurrent actors that can be running at once
_SPAWNED_ACTOR_PIDS: ClassVar[list[psutil.Process]] = [] # used to record all the pids of Actors spawned on the class
### Instance attributes (only used within an actor instance inside a spawned actor thread/process)
pid: int
idle_count: int = 0
launch_kwargs: LaunchKwargs = {}
mode: Literal['thread', 'process'] = 'process'
def __init_subclass__(cls) -> None:
"""
Executed at class definition time (i.e. during import of any file containing class MyActor(ActorType[MyModel]): ...).
Loads the django Model from the Generic[ModelType] TypeVar arg and populates any missing class-level config using it.
"""
if getattr(cls, 'Model', None) is None:
cls.Model = cls._get_model_from_generic_typevar()
cls._populate_missing_classvars_from_model(cls.Model)
def __init__(self, mode: Literal['thread', 'process']|None=None, **launch_kwargs: LaunchKwargs):
"""
Executed right before the Actor is spawned to create a unique Actor instance for that thread/process.
actor_instance.runloop() is then executed from inside the newly spawned thread/process.
"""
self.mode = mode or self.mode
self.launch_kwargs = launch_kwargs or dict(self.launch_kwargs)
### Private Helper Methods: Not desiged to be overridden by subclasses or called by anything outside of this class
@classproperty
def name(cls) -> str:
return cls.__name__ # type: ignore
def __str__(self) -> str:
return repr(self)
def __repr__(self) -> str:
"""-> FaviconActor[pid=1234]"""
label = 'pid' if self.mode == 'process' else 'tid'
return f'[underline]{self.name}[/underline]\\[{label}={self.pid}]'
@staticmethod
def _state_to_str(state: ObjectState) -> str:
"""Convert a statemachine.State, models.TextChoices.choices value, or Enum value to a str"""
return str(state.value) if isinstance(state, State) else str(state)
@staticmethod
def _sql_for_select_top_n_candidates(qs: QuerySet, claim_from_top_n: int=CLAIM_FROM_TOP_N) -> tuple[str, tuple[Any, ...]]:
"""Get the SQL for selecting the top N candidates from the queue (to claim one from)"""
queryset = qs.only('id')[:claim_from_top_n]
select_sql, select_params = compile_sql_select(queryset)
return select_sql, select_params
@staticmethod
def _sql_for_update_claimed_obj(qs: QuerySet, update_kwargs: dict[str, Any]) -> tuple[str, tuple[Any, ...]]:
"""Get the SQL for updating a claimed object to mark it as ACTIVE"""
# qs.update(status='started', retry_at=<now + MAX_TICK_TIME>)
update_sql, update_params = compile_sql_update(qs, update_kwargs=update_kwargs)
# e.g. UPDATE core_archiveresult SET status='%s', retry_at='%s' WHERE status NOT IN ('succeeded', 'failed', 'sealed', 'started') AND retry_at <= '2024-11-04 10:14:33.240903'
return update_sql, update_params
@classmethod
def _get_model_from_generic_typevar(cls) -> Type[ModelType]:
"""Get the django Model from the Generic[ModelType] TypeVar arg (and check that it inherits from django.db.models.Model)"""
# cls.__orig_bases__ is non-standard and may be removed in the future! if this breaks,
# we can just require the inerited class to define the Model as a classvar manually, e.g.:
# class SnapshotActor(ActorType[Snapshot]):
# Model: ClassVar[Type[Snapshot]] = Snapshot
# https://stackoverflow.com/questions/57706180/generict-base-class-how-to-get-type-of-t-from-within-instance
Model = get_args(cls.__orig_bases__[0])[0] # type: ignore
assert issubclass(Model, DjangoModel), f'{cls.__name__}.Model must be a valid django Model'
return cast(Type[ModelType], Model)
@staticmethod
def _get_state_machine_cls(Model: Type[ModelType]) -> Type[StateMachine]:
"""Get the StateMachine class for the given django Model that inherits from MachineMixin"""
assert issubclass(Model, MachineMixin), f'{Model.__name__} must inherit from MachineMixin and define a .state_machine_name: str'
model_state_machine_name = getattr(Model, 'state_machine_name', None)
if model_state_machine_name:
StateMachine = registry.get_machine_cls(model_state_machine_name)
assert issubclass(StateMachine, StateMachine)
return StateMachine
raise NotImplementedError(f'ActorType[{Model.__name__}] must define .state_machine_name: str that points to a valid StateMachine')
@classmethod
def _get_state_machine_instance(cls, obj: ModelType) -> StateMachine:
"""Get the StateMachine instance for the given django Model instance (and check that it is a valid instance of cls.StateMachineClass)"""
obj_statemachine = None
state_machine_attr = getattr(obj, 'state_machine_attr', 'sm')
try:
obj_statemachine = getattr(obj, state_machine_attr)
except Exception:
pass
if not isinstance(obj_statemachine, cls.StateMachineClass):
raise Exception(f'{cls.__name__}: Failed to find a valid StateMachine instance at {type(obj).__name__}.{state_machine_attr}')
return obj_statemachine
@classmethod
def _populate_missing_classvars_from_model(cls, Model: Type[ModelType]):
"""Check that the class variables are set correctly based on the ModelType"""
# check that Model is the same as the Generic[ModelType] parameter in the class definition
cls.Model = getattr(cls, 'Model', None) or Model
if cls.Model != Model:
raise ValueError(f'{cls.__name__}.Model must be set to the same Model as the Generic[ModelType] parameter in the class definition')
# check that Model has a valid StateMachine with the required event defined on it
cls.StateMachineClass = getattr(cls, 'StateMachineClass', None) or cls._get_state_machine_cls(cls.Model)
assert isinstance(cls.EVENT_NAME, str), f'{cls.__name__}.EVENT_NAME must be a str, got: {type(cls.EVENT_NAME).__name__} instead'
assert hasattr(cls.StateMachineClass, cls.EVENT_NAME), f'StateMachine {cls.StateMachineClass.__name__} must define a {cls.EVENT_NAME} event ({cls.__name__}.EVENT_NAME = {cls.EVENT_NAME})'
# check that Model uses .id as its primary key field
primary_key_field = cls.Model._meta.pk.name
if primary_key_field != 'id':
raise NotImplementedError(f'Actors currently only support models that use .id as their primary key field ({cls.__name__} uses {cls.__name__}.{primary_key_field} as primary key)')
# check for STATE_FIELD_NAME classvar or set it from the model's state_field_name attr
if not getattr(cls, 'STATE_FIELD_NAME', None):
if hasattr(cls.Model, 'state_field_name'):
cls.STATE_FIELD_NAME = getattr(cls.Model, 'state_field_name')
else:
raise NotImplementedError(f'{cls.__name__} must define a STATE_FIELD_NAME: ClassVar[str] (e.g. "status") or have a .state_field_name attr on its Model')
assert isinstance(cls.STATE_FIELD_NAME, str), f'{cls.__name__}.STATE_FIELD_NAME must be a str, got: {type(cls.STATE_FIELD_NAME).__name__} instead'
# check for FINAL_STATES classvar or set it from the model's final_states attr
if not getattr(cls, 'FINAL_STATES', None):
cls.FINAL_STATES = cls.StateMachineClass.final_states
if not cls.FINAL_STATES:
raise NotImplementedError(f'{cls.__name__} must define a non-empty FINAL_STATES: ClassVar[list[str]] (e.g. ["sealed"]) or have a {cls.Model.__name__}.state_machine_name pointing to a StateMachine that provides .final_states')
cls.FINAL_STATES = [cls._state_to_str(state) for state in cls.FINAL_STATES]
assert all(isinstance(state, str) for state in cls.FINAL_STATES), f'{cls.__name__}.FINAL_STATES must be a list[str], got: {type(cls.FINAL_STATES).__name__} instead'
# check for ACTIVE_STATE classvar or set it from the model's active_state attr
if not getattr(cls, 'ACTIVE_STATE', None):
raise NotImplementedError(f'{cls.__name__} must define an ACTIVE_STATE: ClassVar[State] (e.g. SnapshotMachine.started) ({cls.Model.__name__}.{cls.STATE_FIELD_NAME} gets set to this value to mark objects as actively processing)')
assert isinstance(cls.ACTIVE_STATE, (State, str)), f'{cls.__name__}.ACTIVE_STATE must be a statemachine.State | str, got: {type(cls.ACTIVE_STATE).__name__} instead'
# check the other ClassVar attributes for valid values
assert cls.CLAIM_ORDER and isinstance(cls.CLAIM_ORDER, tuple) and all(isinstance(order, str) for order in cls.CLAIM_ORDER), f'{cls.__name__}.CLAIM_ORDER must be a non-empty tuple[str, ...], got: {type(cls.CLAIM_ORDER).__name__} instead'
assert cls.CLAIM_FROM_TOP_N > 0, f'{cls.__name__}.CLAIM_FROM_TOP_N must be a positive int, got: {cls.CLAIM_FROM_TOP_N} instead'
assert cls.MAX_TICK_TIME >= 1, f'{cls.__name__}.MAX_TICK_TIME must be a positive int > 1, got: {cls.MAX_TICK_TIME} instead'
assert cls.MAX_CONCURRENT_ACTORS >= 1, f'{cls.__name__}.MAX_CONCURRENT_ACTORS must be a positive int >=1, got: {cls.MAX_CONCURRENT_ACTORS} instead'
assert isinstance(cls.CLAIM_ATOMIC, bool), f'{cls.__name__}.CLAIM_ATOMIC must be a bool, got: {cls.CLAIM_ATOMIC} instead'
@classmethod
def _fork_actor_as_thread(cls, **launch_kwargs: LaunchKwargs) -> int:
"""Spawn a new background thread running the actor's runloop"""
actor = cls(mode='thread', **launch_kwargs)
bg_actor_thread = Thread(target=actor.runloop)
bg_actor_thread.start()
assert bg_actor_thread.native_id is not None
return bg_actor_thread.native_id
@classmethod
def _fork_actor_as_process(cls, **launch_kwargs: LaunchKwargs) -> int:
"""Spawn a new background process running the actor's runloop"""
actor = cls(mode='process', **launch_kwargs)
bg_actor_process = Process(target=actor.runloop)
bg_actor_process.start()
assert bg_actor_process.pid is not None
cls._SPAWNED_ACTOR_PIDS.append(psutil.Process(pid=bg_actor_process.pid))
return bg_actor_process.pid
### Class Methods: Called by Orchestrator on ActorType class before it has been spawned
@classmethod
def get_running_actors(cls) -> list[int]:
"""returns a list of pids of all running actors of this type"""
# WARNING: only works for process actors, not thread actors
if cls.mode == 'thread':
raise NotImplementedError('get_running_actors() is not implemented for thread actors')
return [
proc.pid for proc in cls._SPAWNED_ACTOR_PIDS
if proc.is_running() and proc.status() != 'zombie'
]
@classmethod
def get_actors_to_spawn(cls, queue: QuerySet, running_actors: list[int]) -> list[LaunchKwargs]:
"""Get a list of launch kwargs for the number of actors to spawn based on the queue and currently running actors"""
queue_length = queue.count()
if not queue_length: # queue is empty, spawn 0 actors
return []
# WARNING:
# spawning new actors processes is slow/expensive, avoid spawning many actors at once in a single orchestrator tick.
# limit to spawning 1 or 2 at a time per orchestrator tick, and let the next tick handle starting another couple.
# DONT DO THIS:
# if queue_length > 20: # queue is extremely long, spawn maximum actors at once!
# num_to_spawn_this_tick = cls.MAX_CONCURRENT_ACTORS
if queue_length > 10:
num_to_spawn_this_tick = 2 # spawn more actors per tick if queue is long
else:
num_to_spawn_this_tick = 1 # spawn fewer actors per tick if queue is short
num_remaining = cls.MAX_CONCURRENT_ACTORS - len(running_actors)
num_to_spawn_now: int = limit(num_to_spawn_this_tick, num_remaining)
actors_launch_kwargs: list[LaunchKwargs] = num_to_spawn_now * [{**cls.launch_kwargs}]
return actors_launch_kwargs
@classmethod
def start(cls, mode: Literal['thread', 'process']='process', **launch_kwargs: LaunchKwargs) -> int:
if mode == 'thread':
return cls._fork_actor_as_thread(**launch_kwargs)
elif mode == 'process':
return cls._fork_actor_as_process(**launch_kwargs)
raise ValueError(f'Invalid actor mode: {mode} must be "thread" or "process"')
@classproperty
def qs(cls) -> QuerySet[ModelType]:
"""
Get the unfiltered and unsorted QuerySet of all objects that this Actor might care about.
Override this in the subclass to define the QuerySet of objects that the Actor is going to poll for new work.
(don't limit, order, or filter this by retry_at or status yet, Actor.get_queue() handles that part)
"""
return cls.Model.objects.all()
@classproperty
def final_q(cls) -> Q:
"""Get the filter for objects that are in a final state"""
return Q(**{f'{cls.STATE_FIELD_NAME}__in': [cls._state_to_str(s) for s in cls.FINAL_STATES]})
@classproperty
def active_q(cls) -> Q:
"""Get the filter for objects that are actively processing right now"""
return Q(**{cls.STATE_FIELD_NAME: cls._state_to_str(cls.ACTIVE_STATE)}) # e.g. Q(status='started')
@classproperty
def stalled_q(cls) -> Q:
"""Get the filter for objects that are marked active but have timed out"""
return cls.active_q & Q(retry_at__lte=timezone.now()) # e.g. Q(status='started') AND Q(<retry_at is in the past>)
@classproperty
def future_q(cls) -> Q:
"""Get the filter for objects that have a retry_at in the future"""
return Q(retry_at__gt=timezone.now())
@classproperty
def pending_q(cls) -> Q:
"""Get the filter for objects that are ready for processing."""
return ~(cls.active_q) & ~(cls.final_q) & ~(cls.future_q)
@classmethod
def get_queue(cls, sort: bool=True) -> QuerySet[ModelType]:
"""
Get the sorted and filtered QuerySet of objects that are ready for processing.
e.g. qs.exclude(status__in=('sealed', 'started'), retry_at__gt=timezone.now()).order_by('retry_at')
"""
unsorted_qs = cls.qs.filter(cls.pending_q)
return unsorted_qs.order_by(*cls.CLAIM_ORDER) if sort else unsorted_qs
### Instance Methods: Only called from within Actor instance after it has been spawned (i.e. forked as a thread or process)
def runloop(self):
"""The main runloop that starts running when the actor is spawned (as subprocess or thread) and exits when the queue is empty"""
self.on_startup()
obj_to_process: ModelType | None = None
last_error: BaseException | None = None
try:
while True:
# Get the next object to process from the queue
try:
obj_to_process = cast(ModelType, self.get_next(atomic=self.atomic))
except (ActorQueueIsEmpty, ActorObjectAlreadyClaimed) as err:
last_error = err
obj_to_process = None
# Handle the case where there is no next object to process
if obj_to_process:
self.idle_count = 0 # reset idle count if we got an object
else:
if self.idle_count >= 30:
break # stop looping and exit if queue is empty and we have idled for 30sec
else:
# print('Actor runloop()', f'pid={self.pid}', 'queue empty, rechecking...')
self.idle_count += 1
time.sleep(1)
continue
# Process the object by triggering its StateMachine.tick() method
self.on_tick_start(obj_to_process)
try:
self.tick(obj_to_process)
except Exception as err:
last_error = err
# print(f'[red]🏃‍♂️ {self}.tick()[/red] {obj_to_process} ERROR: [red]{type(err).__name__}: {err}[/red]')
db.connections.close_all() # always reset the db connection after an exception to clear any pending transactions
self.on_tick_exception(obj_to_process, err)
finally:
self.on_tick_end(obj_to_process)
except BaseException as err:
last_error = err
if isinstance(err, KeyboardInterrupt):
print()
else:
print(f'\n[red]🏃‍♂️ {self}.runloop() FATAL:[/red] {type(err).__name__}: {err}')
print(f' Last processed object: {obj_to_process}')
raise
finally:
self.on_shutdown(last_obj=obj_to_process, last_error=last_error)
def get_update_kwargs_to_claim_obj(self) -> dict[str, Any]:
"""
Get the field values needed to mark an pending obj_to_process as being actively processing (aka claimed)
by the current Actor. returned kwargs will be applied using: qs.filter(id=obj_to_process.id).update(**kwargs).
F() expressions are allowed in field values if you need to update a field based on its current value.
Can be a defined as a normal method (instead of classmethod) on subclasses if it needs to access instance vars.
"""
return {
self.STATE_FIELD_NAME: self.ACTIVE_STATE,
'retry_at': timezone.now() + timedelta(seconds=self.MAX_TICK_TIME),
}
def get_next(self, atomic: bool | None=None) -> ModelType | None:
"""get the next object from the queue, atomically locking it if self.atomic=True"""
atomic = self.CLAIM_ATOMIC if atomic is None else atomic
if atomic:
# fetch and claim the next object from in the queue in one go atomically
obj = self.get_next_atomic()
else:
# two-step claim: fetch the next object and lock it in a separate query
obj = self.get_next_non_atomic()
return obj
def get_next_non_atomic(self) -> ModelType:
"""
Naiively selects the top/first object from self.get_queue().order_by(*self.CLAIM_ORDER),
then claims it by running .update(status='started', retry_at=<now + MAX_TICK_TIME>).
Do not use this method if there is more than one Actor racing to get objects from the same queue,
it will be slow/buggy as they'll compete to lock the same object at the same time (TOCTTOU race).
"""
obj = self.get_queue().first()
if obj is None:
raise ActorQueueIsEmpty(f'No next object available in {self}.get_queue()')
locked = self.get_queue().filter(id=obj.id).update(**self.get_update_kwargs_to_claim_obj())
if not locked:
raise ActorObjectAlreadyClaimed(f'Unable to lock the next {self.Model.__name__} object from {self}.get_queue().first()')
return obj
def get_next_atomic(self) -> ModelType | None:
"""
Selects the top n=50 objects from the queue and atomically claims a random one from that set.
This approach safely minimizes contention with other Actors trying to select from the same Queue.
The atomic query is roughly equivalent to the following: (all done in one SQL query to avoid a TOCTTOU race)
top_candidates are selected from: qs.order_by(*CLAIM_ORDER).only('id')[:CLAIM_FROM_TOP_N]
a single candidate is chosen using: qs.filter(id__in=top_n_candidates).order_by('?').first()
the chosen obj is claimed using: qs.filter(id=chosen_obj).update(status=ACTIVE_STATE, retry_at=<now + MAX_TICK_TIME>)
"""
# TODO: if we switch from SQLite to PostgreSQL in the future, we should change this
# to use SELECT FOR UPDATE instead of a subquery + ORDER BY RANDOM() LIMIT 1
# e.g. SELECT id FROM core_archiveresult WHERE status NOT IN (...) AND retry_at <= '...' ORDER BY retry_at ASC LIMIT 50
qs = self.get_queue()
select_top_canidates_sql, select_params = self._sql_for_select_top_n_candidates(qs=qs)
assert select_top_canidates_sql.startswith('SELECT ')
# e.g. UPDATE core_archiveresult SET status='%s', retry_at='%s' WHERE status NOT IN (...) AND retry_at <= '...'
update_claimed_obj_sql, update_params = self._sql_for_update_claimed_obj(qs=qs, update_kwargs=self.get_update_kwargs_to_claim_obj())
assert update_claimed_obj_sql.startswith('UPDATE ')
db_table = self.Model._meta.db_table # e.g. core_archiveresult
# subquery gets the pool of the top candidates e.g. self.get_queue().only('id')[:CLAIM_FROM_TOP_N]
# main query selects a random one from that pool, and claims it using .update(status=ACTIVE_STATE, retry_at=<now + MAX_TICK_TIME>)
# this is all done in one atomic SQL query to avoid TOCTTOU race conditions (as much as possible)
atomic_select_and_update_sql = f"""
{update_claimed_obj_sql} AND "{db_table}"."id" = (
SELECT "{db_table}"."id" FROM (
{select_top_canidates_sql}
) candidates
ORDER BY RANDOM()
LIMIT 1
)
RETURNING *;
"""
try:
return self.Model.objects.raw(atomic_select_and_update_sql, (*update_params, *select_params))[0]
except KeyError:
if self.get_queue().exists():
raise ActorObjectAlreadyClaimed(f'Unable to lock the next {self.Model.__name__} object from {self}.get_queue().first()')
else:
raise ActorQueueIsEmpty(f'No next object available in {self}.get_queue()')
def tick(self, obj_to_process: ModelType) -> None:
"""Call the object.sm.tick() method to process the object"""
print(f'[blue]🏃‍♂️ {self}.tick()[/blue] {obj_to_process}')
# get the StateMachine instance from the object
obj_statemachine = self._get_state_machine_instance(obj_to_process)
# trigger the event on the StateMachine instance
obj_tick_method = getattr(obj_statemachine, self.EVENT_NAME) # e.g. obj_statemachine.tick()
obj_tick_method()
# save the object to persist any state changes
obj_to_process.save()
def on_startup(self) -> None:
if self.mode == 'thread':
self.pid = get_native_id() # thread id
print(f'[green]🏃‍♂️ {self}.on_startup() STARTUP (THREAD)[/green]')
else:
self.pid = os.getpid() # process id
print(f'[green]🏃‍♂️ {self}.on_startup() STARTUP (PROCESS)[/green]')
# abx.pm.hook.on_actor_startup(actor=self)
def on_shutdown(self, last_obj: ModelType | None=None, last_error: BaseException | None=None) -> None:
if isinstance(last_error, KeyboardInterrupt) or last_error is None:
last_error_str = '[green](CTRL-C)[/green]'
elif isinstance(last_error, ActorQueueIsEmpty):
last_error_str = '[green](queue empty)[/green]'
elif isinstance(last_error, ActorObjectAlreadyClaimed):
last_error_str = '[green](queue race)[/green]'
else:
last_error_str = f'[red]{type(last_error).__name__}: {last_error}[/red]'
print(f'[grey53]🏃‍♂️ {self}.on_shutdown() SHUTTING DOWN[/grey53] {last_error_str}')
# abx.pm.hook.on_actor_shutdown(actor=self, last_obj=last_obj, last_error=last_error)
def on_tick_start(self, obj_to_process: ModelType) -> None:
print(f'🏃‍♂️ {self}.on_tick_start() {obj_to_process}')
# abx.pm.hook.on_actor_tick_start(actor=self, obj_to_process=obj)
# self.timer = TimedProgress(self.MAX_TICK_TIME, prefix=' ')
def on_tick_end(self, obj_to_process: ModelType) -> None:
print(f'🏃‍♂️ {self}.on_tick_end() {obj_to_process}')
# abx.pm.hook.on_actor_tick_end(actor=self, obj_to_process=obj_to_process)
# self.timer.end()
def on_tick_exception(self, obj_to_process: ModelType, error: Exception) -> None:
print(f'[red]🏃‍♂️ {self}.on_tick_exception()[/red] {obj_to_process}: [red]{type(error).__name__}: {error}[/red]')
# abx.pm.hook.on_actor_tick_exception(actor=self, obj_to_process=obj_to_process, error=error)
def compile_sql_select(queryset: QuerySet, filter_kwargs: dict[str, Any] | None=None, order_args: tuple[str, ...]=(), limit: int | None=None) -> tuple[str, tuple[Any, ...]]:
"""
Compute the SELECT query SQL for a queryset.filter(**filter_kwargs).order_by(*order_args)[:limit] call
Returns a tuple of (sql, params) where sql is a template string containing %s (unquoted) placeholders for the params
WARNING:
final_sql = sql % params DOES NOT WORK to assemble the final SQL string because the %s placeholders are not quoted/escaped
they should always passed separately to the DB driver so it can do its own quoting/escaping to avoid SQL injection and syntax errors
"""
assert isinstance(queryset, QuerySet), f'compile_sql_select(...) first argument must be a QuerySet, got: {type(queryset).__name__} instead'
assert filter_kwargs is None or isinstance(filter_kwargs, dict), f'compile_sql_select(...) filter_kwargs argument must be a dict[str, Any], got: {type(filter_kwargs).__name__} instead'
assert isinstance(order_args, tuple) and all(isinstance(arg, str) for arg in order_args), f'compile_sql_select(...) order_args argument must be a tuple[str, ...] got: {type(order_args).__name__} instead'
assert limit is None or isinstance(limit, int), f'compile_sql_select(...) limit argument must be an int, got: {type(limit).__name__} instead'
queryset = queryset._chain() # type: ignore # copy queryset to avoid modifying the original
if filter_kwargs:
queryset = queryset.filter(**filter_kwargs)
if order_args:
queryset = queryset.order_by(*order_args)
if limit is not None:
queryset = queryset[:limit]
query = queryset.query
# e.g. SELECT id FROM core_archiveresult WHERE status NOT IN (%s, %s, %s) AND retry_at <= %s ORDER BY retry_at ASC LIMIT 50
select_sql, select_params = query.get_compiler(queryset.db).as_sql()
return select_sql, select_params
def compile_sql_update(queryset: QuerySet, update_kwargs: dict[str, Any], filter_kwargs: dict[str, Any] | None=None) -> tuple[str, tuple[Any, ...]]:
"""
Compute the UPDATE query SQL for a queryset.filter(**filter_kwargs).update(**update_kwargs) call
Returns a tuple of (sql, params) where sql is a template string containing %s (unquoted) placeholders for the params
Based on the django.db.models.QuerySet.update() source code, but modified to return the SQL instead of executing the update
https://github.com/django/django/blob/611bf6c2e2a1b4ab93273980c45150c099ab146d/django/db/models/query.py#L1217
WARNING:
final_sql = sql % params DOES NOT WORK to assemble the final SQL string because the %s placeholders are not quoted/escaped
they should always passed separately to the DB driver so it can do its own quoting/escaping to avoid SQL injection and syntax errors
"""
assert isinstance(queryset, QuerySet), f'compile_sql_update(...) first argument must be a QuerySet, got: {type(queryset).__name__} instead'
assert isinstance(update_kwargs, dict), f'compile_sql_update(...) update_kwargs argument must be a dict[str, Any], got: {type(update_kwargs).__name__} instead'
assert filter_kwargs is None or isinstance(filter_kwargs, dict), f'compile_sql_update(...) filter_kwargs argument must be a dict[str, Any], got: {type(filter_kwargs).__name__} instead'
queryset = queryset._chain() # type: ignore # copy queryset to avoid modifying the original
if filter_kwargs:
queryset = queryset.filter(**filter_kwargs)
queryset.query.clear_ordering(force=True) # clear any ORDER BY clauses
queryset.query.clear_limits() # clear any LIMIT clauses aka slices[:n]
queryset._for_write = True # type: ignore
query = queryset.query.chain(sql.UpdateQuery) # type: ignore
query.add_update_values(update_kwargs) # type: ignore
query.annotations = {} # clear any annotations
# e.g. UPDATE core_archiveresult SET status='%s', retry_at='%s' WHERE status NOT IN (%s, %s, %s) AND retry_at <= %s
update_sql, update_params = query.get_compiler(queryset.db).as_sql()
return update_sql, update_params
+3
View File
@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.
@@ -1,7 +1,6 @@
from django.apps import AppConfig
class WorkersConfig(AppConfig):
class ActorsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "archivebox.workers"
label = "workers"
name = "actors"
+300
View File
@@ -0,0 +1,300 @@
from typing import ClassVar, Type, Iterable
from datetime import datetime, timedelta
from statemachine.mixins import MachineMixin
from django.db import models
from django.utils import timezone
from django.utils.functional import classproperty
from statemachine import registry, StateMachine, State
from django.core import checks
class DefaultStatusChoices(models.TextChoices):
QUEUED = 'queued', 'Queued'
STARTED = 'started', 'Started'
SEALED = 'sealed', 'Sealed'
default_status_field: models.CharField = models.CharField(choices=DefaultStatusChoices.choices, max_length=15, default=DefaultStatusChoices.QUEUED, null=False, blank=False, db_index=True)
default_retry_at_field: models.DateTimeField = models.DateTimeField(default=timezone.now, null=False, db_index=True)
ObjectState = State | str
ObjectStateList = Iterable[ObjectState]
class BaseModelWithStateMachine(models.Model, MachineMixin):
id: models.UUIDField
StatusChoices: ClassVar[Type[models.TextChoices]]
# status: models.CharField
# retry_at: models.DateTimeField
state_machine_name: ClassVar[str]
state_field_name: ClassVar[str]
state_machine_attr: ClassVar[str] = 'sm'
bind_events_as_methods: ClassVar[bool] = True
active_state: ClassVar[ObjectState]
retry_at_field_name: ClassVar[str]
class Meta:
abstract = True
@classmethod
def check(cls, sender=None, **kwargs):
errors = super().check(**kwargs)
found_id_field = False
found_status_field = False
found_retry_at_field = False
for field in cls._meta.get_fields():
if getattr(field, '_is_state_field', False):
if cls.state_field_name == field.name:
found_status_field = True
if getattr(field, 'choices', None) != cls.StatusChoices.choices:
errors.append(checks.Error(
f'{cls.__name__}.{field.name} must have choices set to {cls.__name__}.StatusChoices.choices',
hint=f'{cls.__name__}.{field.name}.choices = {getattr(field, "choices", None)!r}',
obj=cls,
id='actors.E011',
))
if getattr(field, '_is_retry_at_field', False):
if cls.retry_at_field_name == field.name:
found_retry_at_field = True
if field.name == 'id' and getattr(field, 'primary_key', False):
found_id_field = True
if not found_status_field:
errors.append(checks.Error(
f'{cls.__name__}.state_field_name must be defined and point to a StatusField()',
hint=f'{cls.__name__}.state_field_name = {cls.state_field_name!r} but {cls.__name__}.{cls.state_field_name!r} was not found or does not refer to StatusField',
obj=cls,
id='actors.E012',
))
if not found_retry_at_field:
errors.append(checks.Error(
f'{cls.__name__}.retry_at_field_name must be defined and point to a RetryAtField()',
hint=f'{cls.__name__}.retry_at_field_name = {cls.retry_at_field_name!r} but {cls.__name__}.{cls.retry_at_field_name!r} was not found or does not refer to RetryAtField',
obj=cls,
id='actors.E013',
))
if not found_id_field:
errors.append(checks.Error(
f'{cls.__name__} must have an id field that is a primary key',
hint=f'{cls.__name__}.id = {cls.id!r}',
obj=cls,
id='actors.E014',
))
if not isinstance(cls.state_machine_name, str):
errors.append(checks.Error(
f'{cls.__name__}.state_machine_name must be a dotted-import path to a StateMachine class',
hint=f'{cls.__name__}.state_machine_name = {cls.state_machine_name!r}',
obj=cls,
id='actors.E015',
))
try:
cls.StateMachineClass
except Exception as err:
errors.append(checks.Error(
f'{cls.__name__}.state_machine_name must point to a valid StateMachine class, but got {type(err).__name__} {err} when trying to access {cls.__name__}.StateMachineClass',
hint=f'{cls.__name__}.state_machine_name = {cls.state_machine_name!r}',
obj=cls,
id='actors.E016',
))
if cls.INITIAL_STATE not in cls.StatusChoices.values:
errors.append(checks.Error(
f'{cls.__name__}.StateMachineClass.initial_state must be present within {cls.__name__}.StatusChoices',
hint=f'{cls.__name__}.StateMachineClass.initial_state = {cls.StateMachineClass.initial_state!r}',
obj=cls,
id='actors.E017',
))
if cls.ACTIVE_STATE not in cls.StatusChoices.values:
errors.append(checks.Error(
f'{cls.__name__}.active_state must be set to a valid State present within {cls.__name__}.StatusChoices',
hint=f'{cls.__name__}.active_state = {cls.active_state!r}',
obj=cls,
id='actors.E018',
))
for state in cls.FINAL_STATES:
if state not in cls.StatusChoices.values:
errors.append(checks.Error(
f'{cls.__name__}.StateMachineClass.final_states must all be present within {cls.__name__}.StatusChoices',
hint=f'{cls.__name__}.StateMachineClass.final_states = {cls.StateMachineClass.final_states!r}',
obj=cls,
id='actors.E019',
))
break
return errors
@staticmethod
def _state_to_str(state: ObjectState) -> str:
"""Convert a statemachine.State, models.TextChoices.choices value, or Enum value to a str"""
return str(state.value) if isinstance(state, State) else str(state)
@property
def RETRY_AT(self) -> datetime:
return getattr(self, self.retry_at_field_name)
@RETRY_AT.setter
def RETRY_AT(self, value: datetime):
setattr(self, self.retry_at_field_name, value)
@property
def STATE(self) -> str:
return getattr(self, self.state_field_name)
@STATE.setter
def STATE(self, value: str):
setattr(self, self.state_field_name, value)
def bump_retry_at(self, seconds: int = 10):
self.RETRY_AT = timezone.now() + timedelta(seconds=seconds)
@classproperty
def ACTIVE_STATE(cls) -> str:
return cls._state_to_str(cls.active_state)
@classproperty
def INITIAL_STATE(cls) -> str:
return cls._state_to_str(cls.StateMachineClass.initial_state)
@classproperty
def FINAL_STATES(cls) -> list[str]:
return [cls._state_to_str(state) for state in cls.StateMachineClass.final_states]
@classproperty
def FINAL_OR_ACTIVE_STATES(cls) -> list[str]:
return [*cls.FINAL_STATES, cls.ACTIVE_STATE]
@classmethod
def extend_choices(cls, base_choices: Type[models.TextChoices]):
"""
Decorator to extend the base choices with extra choices, e.g.:
class MyModel(ModelWithStateMachine):
@ModelWithStateMachine.extend_choices(ModelWithStateMachine.StatusChoices)
class StatusChoices(models.TextChoices):
SUCCEEDED = 'succeeded'
FAILED = 'failed'
SKIPPED = 'skipped'
"""
assert issubclass(base_choices, models.TextChoices), f'@extend_choices(base_choices) must be a TextChoices class, not {base_choices.__name__}'
def wrapper(extra_choices: Type[models.TextChoices]) -> Type[models.TextChoices]:
joined = {}
for item in base_choices.choices:
joined[item[0]] = item[1]
for item in extra_choices.choices:
joined[item[0]] = item[1]
return models.TextChoices('StatusChoices', joined)
return wrapper
@classmethod
def StatusField(cls, **kwargs) -> models.CharField:
"""
Used on subclasses to extend/modify the status field with updated kwargs. e.g.:
class MyModel(ModelWithStateMachine):
class StatusChoices(ModelWithStateMachine.StatusChoices):
QUEUED = 'queued', 'Queued'
STARTED = 'started', 'Started'
SEALED = 'sealed', 'Sealed'
BACKOFF = 'backoff', 'Backoff'
FAILED = 'failed', 'Failed'
SKIPPED = 'skipped', 'Skipped'
status = ModelWithStateMachine.StatusField(choices=StatusChoices.choices, default=StatusChoices.QUEUED)
"""
default_kwargs = default_status_field.deconstruct()[3]
updated_kwargs = {**default_kwargs, **kwargs}
field = models.CharField(**updated_kwargs)
field._is_state_field = True # type: ignore
return field
@classmethod
def RetryAtField(cls, **kwargs) -> models.DateTimeField:
"""
Used on subclasses to extend/modify the retry_at field with updated kwargs. e.g.:
class MyModel(ModelWithStateMachine):
retry_at = ModelWithStateMachine.RetryAtField(editable=False)
"""
default_kwargs = default_retry_at_field.deconstruct()[3]
updated_kwargs = {**default_kwargs, **kwargs}
field = models.DateTimeField(**updated_kwargs)
field._is_retry_at_field = True # type: ignore
return field
@classproperty
def StateMachineClass(cls) -> Type[StateMachine]:
"""Get the StateMachine class for the given django Model that inherits from MachineMixin"""
model_state_machine_name = getattr(cls, 'state_machine_name', None)
if model_state_machine_name:
StateMachineCls = registry.get_machine_cls(model_state_machine_name)
assert issubclass(StateMachineCls, StateMachine)
return StateMachineCls
raise NotImplementedError(f'ActorType[{cls.__name__}] must define .state_machine_name: str that points to a valid StateMachine')
# @classproperty
# def final_q(cls) -> Q:
# """Get the filter for objects that are in a final state"""
# return Q(**{f'{cls.state_field_name}__in': cls.final_states})
# @classproperty
# def active_q(cls) -> Q:
# """Get the filter for objects that are actively processing right now"""
# return Q(**{cls.state_field_name: cls._state_to_str(cls.active_state)}) # e.g. Q(status='started')
# @classproperty
# def stalled_q(cls) -> Q:
# """Get the filter for objects that are marked active but have timed out"""
# return cls.active_q & Q(retry_at__lte=timezone.now()) # e.g. Q(status='started') AND Q(<retry_at is in the past>)
# @classproperty
# def future_q(cls) -> Q:
# """Get the filter for objects that have a retry_at in the future"""
# return Q(retry_at__gt=timezone.now())
# @classproperty
# def pending_q(cls) -> Q:
# """Get the filter for objects that are ready for processing."""
# return ~(cls.active_q) & ~(cls.final_q) & ~(cls.future_q)
# @classmethod
# def get_queue(cls) -> QuerySet:
# """
# Get the sorted and filtered QuerySet of objects that are ready for processing.
# e.g. qs.exclude(status__in=('sealed', 'started'), retry_at__gt=timezone.now()).order_by('retry_at')
# """
# return cls.objects.filter(cls.pending_q)
class ModelWithStateMachine(BaseModelWithStateMachine):
StatusChoices: ClassVar[Type[DefaultStatusChoices]] = DefaultStatusChoices
status: models.CharField = BaseModelWithStateMachine.StatusField()
retry_at: models.DateTimeField = BaseModelWithStateMachine.RetryAtField()
state_machine_name: ClassVar[str] # e.g. 'core.statemachines.ArchiveResultMachine'
state_field_name: ClassVar[str] = 'status'
state_machine_attr: ClassVar[str] = 'sm'
bind_events_as_methods: ClassVar[bool] = True
active_state: ClassVar[str] = StatusChoices.STARTED
retry_at_field_name: ClassVar[str] = 'retry_at'
class Meta:
abstract = True
+244
View File
@@ -0,0 +1,244 @@
__package__ = 'archivebox.actors'
import os
import time
import itertools
from typing import Dict, Type, Literal, ClassVar
from django.utils.functional import classproperty
from multiprocessing import Process
from threading import Thread, get_native_id
from rich import print
from django.db.models import QuerySet
from django.apps import apps
from .actor import ActorType
class Orchestrator:
pid: int
idle_count: int = 0
actor_types: Dict[str, Type[ActorType]]
mode: Literal['thread', 'process'] = 'process'
def __init__(self, actor_types: Dict[str, Type[ActorType]] | None = None, mode: Literal['thread', 'process'] | None=None):
self.actor_types = actor_types or self.actor_types or self.autodiscover_actor_types()
self.mode = mode or self.mode
def __repr__(self) -> str:
label = 'tid' if self.mode == 'thread' else 'pid'
return f'[underline]{self.name}[/underline]\\[{label}={self.pid}]'
def __str__(self) -> str:
return self.__repr__()
@classproperty
def name(cls) -> str:
return cls.__name__ # type: ignore
def fork_as_thread(self):
self.thread = Thread(target=self.runloop)
self.thread.start()
assert self.thread.native_id is not None
return self.thread.native_id
def fork_as_process(self):
self.process = Process(target=self.runloop)
self.process.start()
assert self.process.pid is not None
return self.process.pid
def start(self) -> int:
if self.mode == 'thread':
return self.fork_as_thread()
elif self.mode == 'process':
return self.fork_as_process()
raise ValueError(f'Invalid orchestrator mode: {self.mode}')
@classmethod
def autodiscover_actor_types(cls) -> Dict[str, Type[ActorType]]:
# returns a Dict of all discovered {actor_type_id: ActorType} across the codebase
# override this method in a subclass to customize the actor types that are used
# return {'Snapshot': SnapshotActorType, 'ArchiveResult_chrome': ChromeActorType, ...}
return {
# look through all models and find all classes that inherit from ActorType
# actor_type.__name__: actor_type
# for actor_type in abx.pm.hook.get_all_ACTORS_TYPES().values()
}
@classmethod
def get_orphaned_objects(cls, all_queues) -> list:
# returns a list of objects that are in the queues of all actor types but not in the queues of any other actor types
all_queued_ids = itertools.chain(*[queue.values('id', flat=True) for queue in all_queues.values()])
orphaned_objects = []
for model in apps.get_models():
if hasattr(model, 'retry_at'):
orphaned_objects.extend(model.objects.filter(retry_at__lt=timezone.now()).exclude(id__in=all_queued_ids))
return orphaned_objects
def on_startup(self):
if self.mode == 'thread':
self.pid = get_native_id()
print(f'[green]👨‍✈️ {self}.on_startup() STARTUP (THREAD)[/green]')
elif self.mode == 'process':
self.pid = os.getpid()
print(f'[green]👨‍✈️ {self}.on_startup() STARTUP (PROCESS)[/green]')
# abx.pm.hook.on_orchestrator_startup(self)
def on_shutdown(self, err: BaseException | None = None):
print(f'[grey53]👨‍✈️ {self}.on_shutdown() SHUTTING DOWN[/grey53]', err or '[green](gracefully)[/green]')
# abx.pm.hook.on_orchestrator_shutdown(self)
def on_tick_started(self, all_queues):
# total_pending = sum(queue.count() for queue in all_queues.values())
# print(f'👨‍✈️ {self}.on_tick_started()', f'total_pending={total_pending}')
# abx.pm.hook.on_orchestrator_tick_started(self, actor_types, all_queues)
pass
def on_tick_finished(self, all_queues, all_existing_actors, all_spawned_actors):
if all_spawned_actors:
total_queue_length = sum(queue.count() for queue in all_queues.values())
print(f'[grey53]👨‍✈️ {self}.on_tick_finished() queue={total_queue_length} existing_actors={len(all_existing_actors)} spawned_actors={len(all_spawned_actors)}[/grey53]')
# abx.pm.hook.on_orchestrator_tick_finished(self, actor_types, all_queues)
def on_idle(self, all_queues):
# print(f'👨‍✈️ {self}.on_idle()')
# abx.pm.hook.on_orchestrator_idle(self)
# check for orphaned objects left behind
if self.idle_count == 60:
orphaned_objects = self.get_orphaned_objects(all_queues)
if orphaned_objects:
print('[red]👨‍✈️ WARNING: some objects may not be processed, no actor has claimed them after 60s:[/red]', orphaned_objects)
def runloop(self):
self.on_startup()
try:
while True:
all_queues = {
actor_type: actor_type.get_queue()
for actor_type in self.actor_types.values()
}
if not all_queues:
raise Exception('Failed to find any actor_types to process')
self.on_tick_started(all_queues)
all_existing_actors = []
all_spawned_actors = []
for actor_type, queue in all_queues.items():
try:
existing_actors = actor_type.get_running_actors()
all_existing_actors.extend(existing_actors)
actors_to_spawn = actor_type.get_actors_to_spawn(queue, existing_actors)
for launch_kwargs in actors_to_spawn:
new_actor_pid = actor_type.start(mode='process', **launch_kwargs)
all_spawned_actors.append(new_actor_pid)
except Exception as err:
print(f'🏃‍♂️ ERROR: {self} Failed to get {actor_type} queue & running actors', err)
except BaseException:
raise
if not any(queue.exists() for queue in all_queues.values()):
self.on_idle(all_queues)
self.idle_count += 1
time.sleep(1)
else:
self.idle_count = 0
self.on_tick_finished(all_queues, all_existing_actors, all_spawned_actors)
time.sleep(1)
except BaseException as err:
if isinstance(err, KeyboardInterrupt):
print()
else:
print(f'\n[red]🏃‍♂️ {self}.runloop() FATAL:[/red]', err.__class__.__name__, err)
self.on_shutdown(err=err)
from archivebox.config.django import setup_django
setup_django()
from core.models import ArchiveResult, Snapshot
from django.utils import timezone
from django import db
from django.db import connection
from crawls.actors import CrawlActor
from .actor_snapshot import SnapshotActor
from abx_plugin_singlefile.actors import SinglefileActor
class FaviconActor(ActorType[ArchiveResult]):
CLAIM_ORDER: ClassVar[str] = 'created_at DESC'
CLAIM_WHERE: ClassVar[str] = 'status = "queued" AND extractor = "favicon"'
CLAIM_SET: ClassVar[str] = 'status = "started"'
@classproperty
def QUERYSET(cls) -> QuerySet:
return ArchiveResult.objects.filter(status='failed', extractor='favicon')
def tick(self, obj: ArchiveResult):
print(f'[grey53]{self}.tick({obj.abid or obj.id}, status={obj.status}) remaining:[/grey53]', self.get_queue().count())
updated = ArchiveResult.objects.filter(id=obj.id, status='started').update(status='success') == 1
if not updated:
raise Exception(f'Failed to update {obj.abid or obj.id}, interrupted by another actor writing to the same object')
obj.refresh_from_db()
obj.save()
class ExtractorsOrchestrator(Orchestrator):
actor_types = {
'CrawlActor': CrawlActor,
'SnapshotActor': SnapshotActor,
'FaviconActor': FaviconActor,
'SinglefileActor': SinglefileActor,
}
if __name__ == '__main__':
orchestrator = ExtractorsOrchestrator()
orchestrator.start()
snap = Snapshot.objects.last()
assert snap is not None
created = 0
while True:
time.sleep(0.05)
# try:
# ArchiveResult.objects.bulk_create([
# ArchiveResult(
# id=uuid.uuid4(),
# snapshot=snap,
# status='failed',
# extractor='favicon',
# cmd=['echo', '"hello"'],
# cmd_version='1.0',
# pwd='.',
# start_ts=timezone.now(),
# end_ts=timezone.now(),
# created_at=timezone.now(),
# modified_at=timezone.now(),
# created_by_id=1,
# )
# for _ in range(100)
# ])
# created += 100
# if created % 1000 == 0:
# print(f'[blue]Created {created} ArchiveResults...[/blue]')
# time.sleep(25)
# except Exception as err:
# print(err)
# db.connections.close_all()
# except BaseException as err:
# print(err)
# break
+3
View File
@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.
+3
View File
@@ -0,0 +1,3 @@
from django.shortcuts import render
# Create your views here.
+1 -1
View File
@@ -1 +1 @@
__package__ = "archivebox.api"
__package__ = 'archivebox.api'
+18 -107
View File
@@ -1,120 +1,31 @@
__package__ = "archivebox.api"
__package__ = 'archivebox.api'
from django import forms
from django.contrib import admin
from django.http import HttpRequest
from django.utils.text import capfirst
from signal_webhooks.admin import WebhookAdmin, WebhookModelForm
from signal_webhooks.settings import webhook_settings
from signal_webhooks.utils import get_webhook_model, model_from_reference
from signal_webhooks.admin import WebhookAdmin
from signal_webhooks.utils import get_webhook_model
from archivebox.base_models.admin import BaseModelAdmin
from abid_utils.admin import ABIDModelAdmin
from archivebox.api.models import APIToken
from api.models import APIToken
def _webhook_fields(*names: str) -> tuple[str, ...]:
model_fields = {field.name for field in get_webhook_model()._meta.fields}
return tuple(name for name in names if name in model_fields)
class APITokenAdmin(ABIDModelAdmin):
list_display = ('created_at', 'abid', 'created_by', 'token_redacted', 'expires')
sort_fields = ('abid', 'created_at', 'created_by', 'expires')
readonly_fields = ('created_at', 'modified_at', 'abid_info')
search_fields = ('id', 'abid', 'created_by__username', 'token')
fields = ('created_by', 'token', 'expires', *readonly_fields)
class APITokenAdmin(BaseModelAdmin):
list_display = ("created_at", "id", "created_by", "token_redacted", "expires")
sort_fields = ("id", "created_at", "created_by", "expires")
readonly_fields = ("created_at", "modified_at")
search_fields = ("id", "created_by__username", "token")
fieldsets = (
(
"Token",
{
"fields": ("token", "expires"),
"classes": ("card",),
},
),
(
"Owner",
{
"fields": ("created_by",),
"classes": ("card",),
},
),
(
"Timestamps",
{
"fields": ("created_at", "modified_at"),
"classes": ("card",),
},
),
)
list_filter = ("created_by",)
ordering = ["-created_at"]
list_filter = ('created_by',)
ordering = ['-created_at']
list_per_page = 100
class OutboundWebhookAdminForm(WebhookModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["ref"] = forms.ChoiceField(
label=self.fields["ref"].label,
help_text=self.fields["ref"].help_text,
choices=[
(ref, f"{capfirst(model_from_reference(ref, check_hooks=False)._meta.verbose_name_plural)} ({ref})")
for ref in sorted(webhook_settings.HOOKS)
],
)
class CustomWebhookAdmin(WebhookAdmin, ABIDModelAdmin):
list_display = ('created_at', 'created_by', 'abid', *WebhookAdmin.list_display)
sort_fields = ('created_at', 'created_by', 'abid', 'referenced_model', 'endpoint', 'last_success', 'last_error')
readonly_fields = ('created_at', 'modified_at', 'abid_info', *WebhookAdmin.readonly_fields)
class CustomWebhookAdmin(WebhookAdmin, BaseModelAdmin):
form = OutboundWebhookAdminForm
list_display = ("created_at", "created_by", "id", *WebhookAdmin.list_display)
sort_fields = _webhook_fields("created_at", "created_by", "id", "ref", "endpoint", "last_success", "last_failure")
readonly_fields = _webhook_fields("created_at", "modified_at", *WebhookAdmin.readonly_fields)
fieldsets = (
(
"Webhook",
{
"fields": _webhook_fields("name", "signal", "ref", "endpoint", "headers", "keep_last_response"),
"classes": ("card", "wide"),
},
),
(
"Authentication",
{
"fields": _webhook_fields("auth_token"),
"classes": ("card",),
},
),
(
"Status",
{
"fields": _webhook_fields("enabled", "last_success", "last_failure", "last_response"),
"classes": ("card",),
},
),
(
"Owner",
{
"fields": _webhook_fields("created_by"),
"classes": ("card",),
},
),
(
"Timestamps",
{
"fields": _webhook_fields("created_at", "modified_at"),
"classes": ("card",),
},
),
)
def lookup_allowed(self, lookup: str, value: str, request: HttpRequest | None = None) -> bool:
"""Preserve WebhookAdmin's auth token filter with Django's current admin signature."""
return not lookup.startswith("auth_token") and admin.ModelAdmin.lookup_allowed(self, lookup, value, request)
def register_admin(admin_site: admin.AdminSite) -> None:
def register_admin(admin_site):
admin_site.register(APIToken, APITokenAdmin)
admin_site.register(get_webhook_model(), CustomWebhookAdmin)
+6 -5
View File
@@ -1,14 +1,15 @@
__package__ = "archivebox.api"
__package__ = 'archivebox.api'
from django.apps import AppConfig
import abx
class APIConfig(AppConfig):
name = "archivebox.api"
label = "api"
name = 'api'
@abx.hookimpl
def register_admin(admin_site):
from archivebox.api.admin import register_admin
from api.admin import register_admin
register_admin(admin_site)
+78 -76
View File
@@ -1,19 +1,21 @@
__package__ = "archivebox.api"
__package__ = 'archivebox.api'
from typing import Any, Optional, cast
from datetime import timedelta
from django.utils import timezone
from django.http import HttpRequest
from django.utils import timezone
from django.contrib.auth import login
from django.contrib.auth import authenticate
from django.contrib.auth.models import User
from django.contrib.auth.models import AbstractBaseUser
from ninja.security import HttpBearer, APIKeyQuery, APIKeyHeader
from ninja.security import HttpBearer, APIKeyQuery, APIKeyHeader, HttpBasicAuth, django_auth_superuser
from ninja.errors import HttpError
def get_or_create_api_token(user: User | None):
from archivebox.api.models import APIToken
def get_or_create_api_token(user):
from api.models import APIToken
if user and user.is_superuser:
api_tokens = APIToken.objects.filter(created_by_id=user.pk, expires__gt=timezone.now())
if api_tokens.exists():
@@ -22,116 +24,116 @@ def get_or_create_api_token(user: User | None):
else:
# does not exist, create a new one
api_token = APIToken.objects.create(created_by_id=user.pk, expires=timezone.now() + timedelta(days=30))
if api_token is None:
return None
assert api_token.is_valid(), f"API token is not valid {api_token}"
return api_token
return None
def auth_using_token(token: str | None, request: HttpRequest | None = None) -> User | None:
def auth_using_token(token, request: Optional[HttpRequest]=None) -> Optional[AbstractBaseUser]:
"""Given an API token string, check if a corresponding non-expired APIToken exists, and return its user"""
from archivebox.api.models import APIToken # lazy import model to avoid loading it at urls.py import time
from api.models import APIToken # lazy import model to avoid loading it at urls.py import time
user = None
user: User | None = None
submitted_empty_form = str(token).strip() in ("string", "", "None", "null")
submitted_empty_form = str(token).strip() in ('string', '', 'None', 'null')
if not submitted_empty_form:
try:
api_token = APIToken.objects.get(token=token)
if api_token.is_valid() and isinstance(api_token.created_by, User):
user = api_token.created_by
if request is not None:
setattr(request, "_api_token", api_token)
token = APIToken.objects.get(token=token)
if token.is_valid():
user = token.created_by
request._api_token = token
except APIToken.DoesNotExist:
pass
return user
if not user:
# print('[❌] Failed to authenticate API user using API Key:', request)
return None
return cast(AbstractBaseUser, user)
def token_from_request(request: HttpRequest) -> str:
token = request.GET.get("api_key") or request.headers.get("X-ArchiveBox-API-Key") or ""
auth_header = request.headers.get("Authorization", "")
if not token and auth_header.lower().startswith("bearer "):
token = auth_header.split(None, 1)[1].strip()
return token
def authenticated_user_from_request(request: HttpRequest) -> User | None:
user = request.user
if user.is_authenticated and user.is_active:
return user
token = token_from_request(request)
token_user = auth_using_token(token=token, request=request) if token else None
if token_user and token_user.is_active:
request.user = token_user
return token_user
return None
def auth_using_password(username: str | None, password: str | None, request: HttpRequest | None = None) -> User | None:
def auth_using_password(username, password, request: Optional[HttpRequest]=None) -> Optional[AbstractBaseUser]:
"""Given a username and password, check if they are valid and return the corresponding user"""
user: User | None = None
submitted_empty_form = (username, password) in (("string", "string"), ("", ""), (None, None))
user = None
submitted_empty_form = (username, password) in (('string', 'string'), ('', ''), (None, None))
if not submitted_empty_form:
authenticated_user = authenticate(
user = authenticate(
username=username,
password=password,
)
if isinstance(authenticated_user, User):
user = authenticated_user
return user
if not user:
# print('[❌] Failed to authenticate API user using API Key:', request)
user = None
return cast(AbstractBaseUser | None, user)
### Base Auth Types
def _require_superuser(user: User | None, request: HttpRequest, auth_method: str) -> User | None:
if user and user.pk:
request.user = user
setattr(request, "_api_auth_method", auth_method)
if not user.is_superuser:
raise HttpError(403, "Valid credentials but User does not have permission (make sure user.is_superuser=True)")
return user
class APITokenAuthCheck:
"""The base class for authentication methods that use an api.models.APIToken"""
def authenticate(self, request: HttpRequest, key: Optional[str]=None) -> Optional[AbstractBaseUser]:
request.user = auth_using_token(
token=key,
request=request,
)
if request.user and request.user.pk:
# Don't set cookie/persist login ouside this erquest, user may be accessing the API from another domain (CSRF/CORS):
# login(request, request.user, backend='django.contrib.auth.backends.ModelBackend')
request._api_auth_method = self.__class__.__name__
if not request.user.is_superuser:
raise HttpError(403, 'Valid API token but User does not have permission (make sure user.is_superuser=True)')
return request.user
class UserPassAuthCheck:
"""The base class for authentication methods that use a username & password"""
def authenticate(self, request: HttpRequest, username: Optional[str]=None, password: Optional[str]=None) -> Optional[AbstractBaseUser]:
request.user = auth_using_password(
username=username,
password=password,
request=request,
)
if request.user and request.user.pk:
# Don't set cookie/persist login ouside this erquest, user may be accessing the API from another domain (CSRF/CORS):
# login(request, request.user, backend='django.contrib.auth.backends.ModelBackend')
request._api_auth_method = self.__class__.__name__
if not request.user.is_superuser:
raise HttpError(403, 'Valid API token but User does not have permission (make sure user.is_superuser=True)')
return request.user
### Django-Ninja-Provided Auth Methods
class HeaderTokenAuth(APIKeyHeader):
class HeaderTokenAuth(APITokenAuthCheck, APIKeyHeader):
"""Allow authenticating by passing X-API-Key=xyz as a request header"""
param_name = "X-ArchiveBox-API-Key"
def authenticate(self, request: HttpRequest, key: str | None) -> User | None:
return _require_superuser(auth_using_token(token=key, request=request), request, self.__class__.__name__)
class BearerTokenAuth(HttpBearer):
class BearerTokenAuth(APITokenAuthCheck, HttpBearer):
"""Allow authenticating by passing Bearer=xyz as a request header"""
pass
def authenticate(self, request: HttpRequest, token: str) -> User | None:
return _require_superuser(auth_using_token(token=token, request=request), request, self.__class__.__name__)
class QueryParamTokenAuth(APIKeyQuery):
class QueryParamTokenAuth(APITokenAuthCheck, APIKeyQuery):
"""Allow authenticating by passing api_key=xyz as a GET/POST query parameter"""
param_name = "api_key"
def authenticate(self, request: HttpRequest, key: str | None) -> User | None:
return _require_superuser(auth_using_token(token=key, request=request), request, self.__class__.__name__)
class UsernameAndPasswordAuth(UserPassAuthCheck, HttpBasicAuth):
"""Allow authenticating by passing username & password via HTTP Basic Authentication (not recommended)"""
pass
### Enabled Auth Methods
API_AUTH_METHODS = [
HeaderTokenAuth(),
BearerTokenAuth(),
QueryParamTokenAuth(),
QueryParamTokenAuth(),
# django_auth_superuser, # django admin cookie auth, not secure to use with csrf=False
UsernameAndPasswordAuth(),
]
-32
View File
@@ -1,32 +0,0 @@
__package__ = "archivebox.api"
from django.http import HttpResponse
class ApiCorsMiddleware:
"""Attach permissive CORS headers for API routes (token-based auth)."""
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
if request.path.startswith("/api/"):
if request.method == "OPTIONS" and request.META.get("HTTP_ACCESS_CONTROL_REQUEST_METHOD"):
response = HttpResponse(status=204)
return self._add_cors_headers(request, response)
response = self.get_response(request)
return self._add_cors_headers(request, response)
return self.get_response(request)
def _add_cors_headers(self, request, response):
origin = request.META.get("HTTP_ORIGIN")
if not origin:
return response
response["Access-Control-Allow-Origin"] = "*"
response["Access-Control-Allow-Methods"] = "GET, POST, PUT, PATCH, DELETE, OPTIONS"
response["Access-Control-Allow-Headers"] = "Authorization, X-ArchiveBox-API-Key, Content-Type, X-CSRFToken"
response["Access-Control-Max-Age"] = "600"
return response
+13 -265
View File
@@ -1,281 +1,29 @@
# Generated by hand on 2025-12-29
# Creates APIToken and OutboundWebhook tables using raw SQL
# Generated by Django 4.2.11 on 2024-04-25 04:19
import api.models
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import django.utils.timezone
from django.conf import settings
from archivebox.uuid_compat import uuid7
from archivebox.base_models.models import get_or_create_system_user_pk
import archivebox.api.models
import signal_webhooks.fields
import signal_webhooks.utils
# Raw sqlite DDL kept EXACTLY as-is; executed only on sqlite via Django's
# RunSQL (identical statement splitting → byte-for-byte identical behavior).
INITIAL_SQL = """
-- Create api_apitoken table
CREATE TABLE IF NOT EXISTS api_apitoken (
id TEXT PRIMARY KEY NOT NULL,
created_at DATETIME NOT NULL,
modified_at DATETIME NOT NULL,
token VARCHAR(32) NOT NULL UNIQUE,
expires DATETIME,
created_by_id INTEGER NOT NULL,
FOREIGN KEY (created_by_id) REFERENCES auth_user(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS api_apitoken_created_by_id_idx ON api_apitoken(created_by_id);
CREATE INDEX IF NOT EXISTS api_apitoken_created_at_idx ON api_apitoken(created_at);
CREATE INDEX IF NOT EXISTS api_apitoken_token_idx ON api_apitoken(token);
-- Create api_outboundwebhook table
CREATE TABLE IF NOT EXISTS api_outboundwebhook (
id TEXT PRIMARY KEY NOT NULL,
created_at DATETIME NOT NULL,
modified_at DATETIME NOT NULL,
name VARCHAR(255) NOT NULL UNIQUE,
signal VARCHAR(255) NOT NULL,
ref VARCHAR(1024) NOT NULL,
endpoint VARCHAR(2048) NOT NULL,
headers TEXT NOT NULL DEFAULT '{}',
auth_token TEXT NOT NULL DEFAULT '',
enabled BOOLEAN NOT NULL DEFAULT 1,
keep_last_response BOOLEAN NOT NULL DEFAULT 0,
created DATETIME NOT NULL,
updated DATETIME NOT NULL,
last_response TEXT NOT NULL DEFAULT '',
last_success DATETIME,
last_failure DATETIME,
created_by_id INTEGER NOT NULL,
FOREIGN KEY (created_by_id) REFERENCES auth_user(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS api_outboundwebhook_created_by_id_idx ON api_outboundwebhook(created_by_id);
CREATE INDEX IF NOT EXISTS api_outboundwebhook_created_at_idx ON api_outboundwebhook(created_at);
CREATE INDEX IF NOT EXISTS api_outboundwebhook_name_idx ON api_outboundwebhook(name);
CREATE INDEX IF NOT EXISTS api_outboundwebhook_ref_idx ON api_outboundwebhook(ref);
"""
INITIAL_REVERSE_SQL = """
DROP TABLE IF EXISTS api_outboundwebhook;
DROP TABLE IF EXISTS api_apitoken;
"""
def _run_sqlite_only_sql(apps, schema_editor):
if schema_editor.connection.vendor != "sqlite":
return
migrations.RunSQL(sql=INITIAL_SQL, reverse_sql=INITIAL_REVERSE_SQL).database_forwards(
"api",
schema_editor,
None,
None,
)
def _run_sqlite_only_sql_reverse(apps, schema_editor):
if schema_editor.connection.vendor != "sqlite":
return
migrations.RunSQL(sql=INITIAL_SQL, reverse_sql=INITIAL_REVERSE_SQL).database_backwards(
"api",
schema_editor,
None,
None,
)
def _pg_sync_schema(apps, schema_editor):
from archivebox.misc.db import rebuild_models_from_migration_state
rebuild_models_from_migration_state(apps, schema_editor, "api", ["APIToken", "OutboundWebhook"])
def _pg_drop_schema(apps, schema_editor):
from archivebox.misc.db import drop_models_on_postgres
drop_models_on_postgres(apps, schema_editor, "api", ["OutboundWebhook", "APIToken"])
import uuid
class Migration(migrations.Migration):
initial = True
dependencies = [
("auth", "0012_alter_user_first_name_max_length"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[
migrations.RunPython(_run_sqlite_only_sql, reverse_code=_run_sqlite_only_sql_reverse),
],
state_operations=[
migrations.CreateModel(
name="APIToken",
fields=[
("id", models.UUIDField(default=uuid7, editable=False, primary_key=True, serialize=False, unique=True)),
("created_at", models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
("modified_at", models.DateTimeField(auto_now=True)),
("token", models.CharField(default=archivebox.api.models.generate_secret_token, max_length=32, unique=True)),
("expires", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
default=get_or_create_system_user_pk,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "API Key",
"verbose_name_plural": "API Keys",
"app_label": "api",
},
),
migrations.CreateModel(
name="OutboundWebhook",
fields=[
("id", models.UUIDField(default=uuid7, editable=False, primary_key=True, serialize=False, unique=True)),
("created_at", models.DateTimeField(db_index=True, default=django.utils.timezone.now)),
("modified_at", models.DateTimeField(auto_now=True)),
(
"name",
models.CharField(db_index=True, help_text="Webhook name.", max_length=255, unique=True, verbose_name="name"),
),
(
"signal",
models.CharField(
choices=[
("CREATE", "Create"),
("UPDATE", "Update"),
("DELETE", "Delete"),
("M2M", "M2M changed"),
("CREATE_OR_UPDATE", "Create or Update"),
("CREATE_OR_DELETE", "Create or Delete"),
("CREATE_OR_M2M", "Create or M2M changed"),
("UPDATE_OR_DELETE", "Update or Delete"),
("UPDATE_OR_M2M", "Update or M2M changed"),
("DELETE_OR_M2M", "Delete or M2M changed"),
("CREATE_UPDATE_OR_DELETE", "Create, Update or Delete"),
("CREATE_UPDATE_OR_M2M", "Create, Update or M2M changed"),
("CREATE_DELETE_OR_M2M", "Create, Delete or M2M changed"),
("UPDATE_DELETE_OR_M2M", "Update, Delete or M2M changed"),
("CREATE_UPDATE_DELETE_OR_M2M", "Create, Update or Delete, or M2M changed"),
],
help_text="Signal the webhook fires to.",
max_length=255,
verbose_name="signal",
),
),
(
"ref",
models.CharField(
db_index=True,
help_text="Dot import notation to the model the webhook is for.",
max_length=1023,
validators=[signal_webhooks.utils.model_from_reference],
verbose_name="referenced model",
),
),
(
"endpoint",
models.URLField(help_text="Target endpoint for this webhook.", max_length=2047, verbose_name="endpoint"),
),
(
"headers",
models.JSONField(
blank=True,
default=dict,
help_text="Headers to send with the webhook request.",
validators=[signal_webhooks.utils.is_dict],
verbose_name="headers",
),
),
(
"auth_token",
signal_webhooks.fields.TokenField(
blank=True,
default="",
help_text="Authentication token to use in an Authorization header.",
max_length=8000,
validators=[signal_webhooks.utils.decode_cipher_key],
verbose_name="authentication token",
),
),
("enabled", models.BooleanField(default=True, help_text="Is this webhook enabled?", verbose_name="enabled")),
(
"keep_last_response",
models.BooleanField(
default=False,
help_text="Should the webhook keep a log of the latest response it got?",
verbose_name="keep last response",
),
),
(
"created",
models.DateTimeField(auto_now_add=True, help_text="When the webhook was created.", verbose_name="created"),
),
(
"updated",
models.DateTimeField(auto_now=True, help_text="When the webhook was last updated.", verbose_name="updated"),
),
(
"last_response",
models.CharField(
blank=True,
default="",
help_text="Latest response to this webhook.",
max_length=8000,
verbose_name="last response",
),
),
(
"last_success",
models.DateTimeField(
default=None,
help_text="When the webhook last succeeded.",
null=True,
verbose_name="last success",
),
),
(
"last_failure",
models.DateTimeField(
default=None,
help_text="When the webhook last failed.",
null=True,
verbose_name="last failure",
),
),
(
"created_by",
models.ForeignKey(
default=get_or_create_system_user_pk,
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"verbose_name": "API Outbound Webhook",
"app_label": "api",
},
),
migrations.AddConstraint(
model_name="outboundwebhook",
constraint=models.UniqueConstraint(fields=["ref", "endpoint"], name="prevent_duplicate_hooks_api_outboundwebhook"),
),
migrations.CreateModel(
name='APIToken',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('token', models.CharField(default=api.models.generate_secret_token, max_length=32, unique=True)),
('created', models.DateTimeField(auto_now_add=True)),
('expires', models.DateTimeField(blank=True, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
),
# On non-sqlite backends the raw DDL above is skipped, so the real
# schema diverges from Django state. Rebuild both tables (including the
# unique constraint) from the post-migration state (no-op on sqlite).
migrations.RunPython(_pg_sync_schema, reverse_code=_pg_drop_schema),
]
@@ -0,0 +1,17 @@
# Generated by Django 5.0.4 on 2024-04-26 05:28
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('api', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='apitoken',
options={'verbose_name': 'API Key', 'verbose_name_plural': 'API Keys'},
),
]
@@ -0,0 +1,77 @@
# Generated by Django 5.0.6 on 2024-06-03 01:52
import abid_utils.models
import charidfield.fields
import django.db.models.deletion
import signal_webhooks.fields
import signal_webhooks.utils
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0002_alter_apitoken_options'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.RenameField(
model_name='apitoken',
old_name='user',
new_name='created_by',
),
migrations.AddField(
model_name='apitoken',
name='abid',
field=charidfield.fields.CharIDField(blank=True, db_index=True, default=None, help_text='ABID-format identifier for this entity (e.g. snp_01BJQMF54D093DXEAWZ6JYRPAQ)', max_length=30, null=True, prefix='apt_', unique=True),
),
migrations.AddField(
model_name='apitoken',
name='modified',
field=models.DateTimeField(auto_now=True),
),
migrations.AddField(
model_name='apitoken',
name='uuid',
field=models.UUIDField(blank=True, null=True, unique=True),
),
migrations.AlterField(
model_name='apitoken',
name='id',
field=models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False),
),
migrations.CreateModel(
name='OutboundWebhook',
fields=[
('name', models.CharField(db_index=True, help_text='Give your webhook a descriptive name (e.g. Notify ACME Slack channel of any new ArchiveResults).', max_length=255, unique=True, verbose_name='name')),
('signal', models.CharField(choices=[('CREATE', 'Create'), ('UPDATE', 'Update'), ('DELETE', 'Delete'), ('M2M', 'M2M changed'), ('CREATE_OR_UPDATE', 'Create or Update'), ('CREATE_OR_DELETE', 'Create or Delete'), ('CREATE_OR_M2M', 'Create or M2M changed'), ('UPDATE_OR_DELETE', 'Update or Delete'), ('UPDATE_OR_M2M', 'Update or M2M changed'), ('DELETE_OR_M2M', 'Delete or M2M changed'), ('CREATE_UPDATE_OR_DELETE', 'Create, Update or Delete'), ('CREATE_UPDATE_OR_M2M', 'Create, Update or M2M changed'), ('CREATE_DELETE_OR_M2M', 'Create, Delete or M2M changed'), ('UPDATE_DELETE_OR_M2M', 'Update, Delete or M2M changed'), ('CREATE_UPDATE_DELETE_OR_M2M', 'Create, Update or Delete, or M2M changed')], help_text='The type of event the webhook should fire for (e.g. Create, Update, Delete).', max_length=255, verbose_name='signal')),
('ref', models.CharField(db_index=True, help_text='Dot import notation of the model the webhook should fire for (e.g. core.models.Snapshot or core.models.ArchiveResult).', max_length=1023, validators=[signal_webhooks.utils.model_from_reference], verbose_name='referenced model')),
('endpoint', models.URLField(help_text='External URL to POST the webhook notification to (e.g. https://someapp.example.com/webhook/some-webhook-receiver).', max_length=2047, verbose_name='endpoint')),
('headers', models.JSONField(blank=True, default=dict, help_text='Headers to send with the webhook request.', validators=[signal_webhooks.utils.is_dict], verbose_name='headers')),
('auth_token', signal_webhooks.fields.TokenField(blank=True, default='', help_text='Authentication token to use in an Authorization header.', max_length=8000, validators=[signal_webhooks.utils.decode_cipher_key], verbose_name='authentication token')),
('enabled', models.BooleanField(default=True, help_text='Is this webhook enabled?', verbose_name='enabled')),
('keep_last_response', models.BooleanField(default=False, help_text='Should the webhook keep a log of the latest response it got?', verbose_name='keep last response')),
('updated', models.DateTimeField(auto_now=True, help_text='When the webhook was last updated.', verbose_name='updated')),
('last_response', models.CharField(blank=True, default='', help_text='Latest response to this webhook.', max_length=8000, verbose_name='last response')),
('last_success', models.DateTimeField(default=None, help_text='When the webhook last succeeded.', null=True, verbose_name='last success')),
('last_failure', models.DateTimeField(default=None, help_text='When the webhook last failed.', null=True, verbose_name='last failure')),
('created', models.DateTimeField(auto_now_add=True)),
('modified', models.DateTimeField(auto_now=True)),
('id', models.UUIDField(blank=True, null=True, unique=True)),
('uuid', models.UUIDField(default=uuid.uuid4, primary_key=True, serialize=False)),
('abid', charidfield.fields.CharIDField(blank=True, db_index=True, default=None, help_text='ABID-format identifier for this entity (e.g. snp_01BJQMF54D093DXEAWZ6JYRPAQ)', max_length=30, null=True, prefix='whk_', unique=True)),
('created_by', models.ForeignKey(default=abid_utils.models.get_or_create_system_user_pk, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'API Outbound Webhook',
'abstract': False,
},
),
migrations.AddConstraint(
model_name='outboundwebhook',
constraint=models.UniqueConstraint(fields=('ref', 'endpoint'), name='prevent_duplicate_hooks_api_outboundwebhook'),
),
]
@@ -0,0 +1,24 @@
# Generated by Django 5.1 on 2024-08-20 10:44
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0003_rename_user_apitoken_created_by_apitoken_abid_and_more'),
]
operations = [
migrations.AlterField(
model_name='apitoken',
name='id',
field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='apitoken',
name='uuid',
field=models.UUIDField(blank=True, editable=False, null=True, unique=True),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.1 on 2024-08-20 22:40
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0004_alter_apitoken_id_alter_apitoken_uuid'),
]
operations = [
migrations.RemoveField(
model_name='apitoken',
name='uuid',
),
migrations.RemoveField(
model_name='outboundwebhook',
name='id',
),
]
@@ -0,0 +1,29 @@
# Generated by Django 5.1 on 2024-08-20 22:43
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0005_remove_apitoken_uuid_remove_outboundwebhook_uuid_and_more'),
]
operations = [
migrations.RenameField(
model_name='outboundwebhook',
old_name='uuid',
new_name='id'
),
migrations.AlterField(
model_name='outboundwebhook',
name='id',
field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='apitoken',
name='id',
field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.1 on 2024-08-20 22:52
import abid_utils.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0006_remove_outboundwebhook_uuid_apitoken_id_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='apitoken',
name='created_by',
field=models.ForeignKey(default=abid_utils.models.get_or_create_system_user_pk, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
]
@@ -0,0 +1,47 @@
# Generated by Django 5.1 on 2024-09-04 23:32
import abid_utils.models
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0007_alter_apitoken_created_by'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='apitoken',
name='created',
field=abid_utils.models.AutoDateTimeField(db_index=True, default=None),
),
migrations.AlterField(
model_name='apitoken',
name='created_by',
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='apitoken',
name='id',
field=models.UUIDField(default=None, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='ID'),
),
migrations.AlterField(
model_name='outboundwebhook',
name='created',
field=abid_utils.models.AutoDateTimeField(db_index=True, default=None),
),
migrations.AlterField(
model_name='outboundwebhook',
name='created_by',
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='outboundwebhook',
name='id',
field=models.UUIDField(default=None, editable=False, primary_key=True, serialize=False, unique=True, verbose_name='ID'),
),
]
@@ -0,0 +1,39 @@
# Generated by Django 5.1 on 2024-09-05 00:26
import abid_utils.models
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0008_alter_apitoken_created_alter_apitoken_created_by_and_more'),
]
operations = [
migrations.RenameField(
model_name='apitoken',
old_name='created',
new_name='created_at',
),
migrations.RenameField(
model_name='apitoken',
old_name='modified',
new_name='modified_at',
),
migrations.RenameField(
model_name='outboundwebhook',
old_name='modified',
new_name='modified_at',
),
migrations.AddField(
model_name='outboundwebhook',
name='created_at',
field=abid_utils.models.AutoDateTimeField(db_index=True, default=None),
),
migrations.AlterField(
model_name='outboundwebhook',
name='created',
field=models.DateTimeField(auto_now_add=True, help_text='When the webhook was created.', verbose_name='created'),
),
]
Executable → Regular
+91 -18
View File
@@ -1,54 +1,127 @@
__package__ = "archivebox.api"
__package__ = 'archivebox.api'
import secrets
from archivebox.uuid_compat import CompactUUIDField, uuid7
from datetime import timedelta
from django.conf import settings
from django.db import models
from django.utils import timezone
from django_stubs_ext.db.models import TypedModelMeta
from signal_webhooks.models import WebhookBase
from archivebox.base_models.models import get_or_create_system_user_pk
from django_stubs_ext.db.models import TypedModelMeta
from abid_utils.models import ABIDModel, ABIDField, AutoDateTimeField
def generate_secret_token() -> str:
# returns cryptographically secure string with len() == 32
return secrets.token_hex(16)
class APIToken(models.Model):
id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk, null=False)
created_at = models.DateTimeField(default=timezone.now, db_index=True)
class APIToken(ABIDModel):
"""
A secret key generated by a User that's used to authenticate REST API requests to ArchiveBox.
"""
# ABID: apt_<created_ts>_<token_hash>_<user_id_hash>_<uuid_rand>
abid_prefix = 'apt_'
abid_ts_src = 'self.created_at'
abid_uri_src = 'self.created_by_id'
abid_subtype_src = '"01"'
abid_rand_src = 'self.id'
abid_drift_allowed = True
id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID')
abid = ABIDField(prefix=abid_prefix)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=None, null=False)
created_at = AutoDateTimeField(default=None, null=False, db_index=True)
modified_at = models.DateTimeField(auto_now=True)
token = models.CharField(max_length=32, default=generate_secret_token, unique=True)
expires = models.DateTimeField(null=True, blank=True)
class Meta(TypedModelMeta):
app_label = "api"
verbose_name = "API Key"
verbose_name_plural = "API Keys"
def __str__(self) -> str:
return self.token
def __repr__(self) -> str:
return f'<APIToken user={self.created_by.username} token={self.token_redacted}>'
def __json__(self) -> dict:
return {
"TYPE": "APIToken",
"id": str(self.pk),
"abid": str(self.ABID),
"created_by_id": str(self.created_by_id),
"token": self.token,
"created_at": self.created_at.isoformat(),
"expires": self.expires_as_iso8601,
}
@property
def expires_as_iso8601(self):
"""Returns the expiry date of the token in ISO 8601 format or a date 100 years in the future if none."""
expiry_date = self.expires or (timezone.now() + timedelta(days=365 * 100))
return expiry_date.isoformat()
@property
def token_redacted(self):
return f"************{self.token[-4:]}"
return f'************{self.token[-4:]}'
def is_valid(self, for_date=None):
return not self.expires or self.expires >= (for_date or timezone.now())
for_date = for_date or timezone.now()
if self.expires and self.expires < for_date:
return False
return True
class OutboundWebhook(WebhookBase):
id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=get_or_create_system_user_pk, null=False)
created_at = models.DateTimeField(default=timezone.now, db_index=True)
# monkey patch django-signals-webhooks to change how it shows up in Admin UI
class OutboundWebhook(ABIDModel, WebhookBase):
"""
Model used in place of (extending) signals_webhooks.models.WebhookModel. Swapped using:
settings.SIGNAL_WEBHOOKS_CUSTOM_MODEL = 'api.models.OutboundWebhook'
"""
abid_prefix = 'whk_'
abid_ts_src = 'self.created_at'
abid_uri_src = 'self.endpoint'
abid_subtype_src = 'self.ref'
abid_rand_src = 'self.id'
abid_drift_allowed = True
id = models.UUIDField(primary_key=True, default=None, null=False, editable=False, unique=True, verbose_name='ID')
abid = ABIDField(prefix=abid_prefix)
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, default=None, null=False)
created_at = AutoDateTimeField(default=None, null=False, db_index=True)
modified_at = models.DateTimeField(auto_now=True)
# More fields here: WebhookBase...
WebhookBase._meta.get_field('name').help_text = (
'Give your webhook a descriptive name (e.g. Notify ACME Slack channel of any new ArchiveResults).')
WebhookBase._meta.get_field('signal').help_text = (
'The type of event the webhook should fire for (e.g. Create, Update, Delete).')
WebhookBase._meta.get_field('ref').help_text = (
'Dot import notation of the model the webhook should fire for (e.g. core.models.Snapshot or core.models.ArchiveResult).')
WebhookBase._meta.get_field('endpoint').help_text = (
'External URL to POST the webhook notification to (e.g. https://someapp.example.com/webhook/some-webhook-receiver).')
class Meta(WebhookBase.Meta):
app_label = "api"
verbose_name = "API Outbound Webhook"
verbose_name = 'API Outbound Webhook'
def __str__(self) -> str:
return f"[{self.id}] {self.ref} -> {self.endpoint}"
return f'[{self.abid}] {self.ref} -> {self.endpoint}'
+30
View File
@@ -0,0 +1,30 @@
__package__ = 'archivebox.api'
# from django.test import TestCase
# from ninja.testing import TestClient
# from .routes_cli import router
# class ArchiveBoxCLIAPITestCase(TestCase):
# def setUp(self):
# self.client = TestClient(router)
# def test_add_endpoint(self):
# response = self.client.post("/add", json={"urls": ["http://example.com"], "tag": "testTag1,testTag2"})
# self.assertEqual(response.status_code, 200)
# self.assertTrue(response.json()["success"])
# def test_remove_endpoint(self):
# response = self.client.post("/remove", json={"filter_patterns": ["http://example.com"]})
# self.assertEqual(response.status_code, 200)
# self.assertTrue(response.json()["success"])
# def test_update_endpoint(self):
# response = self.client.post("/update", json={})
# self.assertEqual(response.status_code, 200)
# self.assertTrue(response.json()["success"])
# def test_list_all_endpoint(self):
# response = self.client.post("/list_all", json={})
# self.assertEqual(response.status_code, 200)
# self.assertTrue(response.json()["success"])
+9 -18
View File
@@ -1,26 +1,17 @@
__package__ = "archivebox.api"
__package__ = 'archivebox.api'
from urllib.parse import quote
from django.http import HttpRequest, HttpResponseRedirect
from django.shortcuts import redirect
from django.urls import path
from django.views.generic.base import RedirectView
from archivebox.core.routes_util import build_web_url
from .v1_api import urls as v1_api_urls
def archive_redirect_view(request: HttpRequest, url: str) -> HttpResponseRedirect:
if request.META.get("QUERY_STRING"):
url = f"{url}?{request.META['QUERY_STRING']}"
return redirect(build_web_url(f"/web/{quote(url, safe=':/')}", request=request))
urlpatterns = [
path("", RedirectView.as_view(url="/api/v1/docs")),
path("archive/<path:url>", archive_redirect_view, name="api-archive-redirect"),
path("v1/", RedirectView.as_view(url="/api/v1/docs")),
path("v1/", v1_api_urls),
path("v1", RedirectView.as_view(url="/api/v1/docs")),
path("", RedirectView.as_view(url='/api/v1')),
path("v1/", v1_api_urls),
path("v1", RedirectView.as_view(url='/api/v1/docs')),
# ... v2 can be added here ...
# path("v2/", v2_api_urls),
# path("v2", RedirectView.as_view(url='/api/v2/docs')),
]
+51 -33
View File
@@ -1,4 +1,4 @@
__package__ = "archivebox.api"
__package__ = 'archivebox.api'
from io import StringIO
@@ -7,7 +7,6 @@ from contextlib import redirect_stdout, redirect_stderr
from django.http import HttpRequest, HttpResponse
from django.core.exceptions import ObjectDoesNotExist, EmptyResultSet, PermissionDenied
from django.contrib.auth.models import User
from ninja import NinjaAPI, Swagger
@@ -16,13 +15,12 @@ from ninja import NinjaAPI, Swagger
from archivebox.config import VERSION
from archivebox.config.version import get_COMMIT_HASH
from archivebox.api.auth import API_AUTH_METHODS
from archivebox.api.models import APIToken
from api.auth import API_AUTH_METHODS
COMMIT_HASH = get_COMMIT_HASH() or "unknown"
COMMIT_HASH = get_COMMIT_HASH() or 'unknown'
html_description = f"""
html_description=f'''
<h3>Welcome to your ArchiveBox server's REST API <code>[v1 ALPHA]</code> homepage!</h3>
<br/>
<i><b>WARNING: This API is still in an early development stage and may change!</b></i>
@@ -35,59 +33,61 @@ html_description = f"""
<li>📜 See the API source code: <a href="https://github.com/ArchiveBox/ArchiveBox/blob/dev/archivebox/api"><code>archivebox/api/</code></a></li>
</ul>
<small>Served by ArchiveBox v{VERSION} (<a href="https://github.com/ArchiveBox/ArchiveBox/commit/{COMMIT_HASH}"><code>{COMMIT_HASH[:8]}</code></a>), API powered by <a href="https://django-ninja.dev/"><code>django-ninja</code></a>.</small>
"""
'''
def register_urls(api: NinjaAPI) -> NinjaAPI:
api.add_router("/auth/", "archivebox.api.v1_auth.router")
api.add_router("/core/", "archivebox.api.v1_core.router")
api.add_router("/crawls/", "archivebox.api.v1_crawls.router")
api.add_router("/cli/", "archivebox.api.v1_cli.router")
api.add_router("/machine/", "archivebox.api.v1_machine.router")
api.add_router("/personas/", "archivebox.api.v1_personas.router")
api.add_router('/auth/', 'api.v1_auth.router')
api.add_router('/core/', 'api.v1_core.router')
api.add_router('/cli/', 'api.v1_cli.router')
return api
class NinjaAPIWithIOCapture(NinjaAPI):
class NinjaAPIWithIOCapture(NinjaAPI):
def create_temporal_response(self, request: HttpRequest) -> HttpResponse:
stdout, stderr = StringIO(), StringIO()
with redirect_stderr(stderr):
with redirect_stdout(stdout):
setattr(request, "stdout", stdout)
setattr(request, "stderr", stderr)
request.stdout = stdout
request.stderr = stderr
response = super().create_temporal_response(request)
# Disable caching of API responses entirely
response["Cache-Control"] = "no-store"
# Diable caching of API responses entirely
response['Cache-Control'] = 'no-store'
# Add debug stdout and stderr headers to response
response["X-ArchiveBox-Stdout"] = stdout.getvalue().replace("\n", "\\n")[:200]
response["X-ArchiveBox-Stderr"] = stderr.getvalue().replace("\n", "\\n")[:200]
response['X-ArchiveBox-Stdout'] = str(request.stdout)[200:]
response['X-ArchiveBox-Stderr'] = str(request.stderr)[200:]
# response['X-ArchiveBox-View'] = self.get_openapi_operation_id(request) or 'Unknown'
# Add Auth Headers to response
api_token_attr = request.__dict__.get("_api_token")
api_token = api_token_attr if isinstance(api_token_attr, APIToken) else None
token_expiry = api_token.expires.isoformat() if api_token and api_token.expires else "Never"
api_token = getattr(request, '_api_token', None)
token_expiry = api_token.expires.isoformat() if api_token and api_token.expires else 'Never'
response["X-ArchiveBox-Auth-Method"] = str(request.__dict__.get("_api_auth_method", "None"))
response["X-ArchiveBox-Auth-Expires"] = token_expiry
response["X-ArchiveBox-Auth-Token-Id"] = str(api_token.id) if api_token else "None"
response["X-ArchiveBox-Auth-User-Id"] = str(request.user.pk) if request.user.pk else "None"
response["X-ArchiveBox-Auth-User-Username"] = request.user.username if isinstance(request.user, User) else "None"
response['X-ArchiveBox-Auth-Method'] = getattr(request, '_api_auth_method', None) or 'None'
response['X-ArchiveBox-Auth-Expires'] = token_expiry
response['X-ArchiveBox-Auth-Token-Id'] = api_token.abid if api_token else 'None'
response['X-ArchiveBox-Auth-User-Id'] = request.user.pk if request.user.pk else 'None'
response['X-ArchiveBox-Auth-User-Username'] = request.user.username if request.user.pk else 'None'
# import ipdb; ipdb.set_trace()
# print('RESPONDING NOW', response)
return response
api = NinjaAPIWithIOCapture(
title="ArchiveBox API",
title='ArchiveBox API',
description=html_description,
version=VERSION,
version='1.0.0',
csrf=False,
auth=API_AUTH_METHODS,
urls_namespace="api-1",
docs=Swagger(settings={"persistAuthorization": True}),
# docs_decorator=login_required,
# renderer=ORJSONRenderer(),
)
api = register_urls(api)
urls = api.urls
@@ -99,16 +99,34 @@ def generic_exception_handler(request, err):
if isinstance(err, (ObjectDoesNotExist, EmptyResultSet, PermissionDenied)):
status = 404
print("".join(format_exception(err)))
print(''.join(format_exception(err)))
return api.create_response(
request,
{
"succeeded": False,
"message": f"{err.__class__.__name__}: {err}",
"message": f'{err.__class__.__name__}: {err}',
"errors": [
"".join(format_exception(err)),
''.join(format_exception(err)),
# or send simpler parent-only traceback:
# *([str(err.__context__)] if getattr(err, '__context__', None) else []),
],
},
status=status,
)
# import orjson
# from ninja.renderers import BaseRenderer
# class ORJSONRenderer(BaseRenderer):
# media_type = "application/json"
# def render(self, request, data, *, response_status):
# return {
# "success": True,
# "errors": [],
# "result": data,
# "stdout": ansi_to_html(stdout.getvalue().strip()),
# "stderr": ansi_to_html(stderr.getvalue().strip()),
# }
# return orjson.dumps(data)
+17 -70
View File
@@ -1,28 +1,26 @@
__package__ = "archivebox.api"
__package__ = 'archivebox.api'
from django.http import HttpRequest
from typing import Optional
from ninja import Router, Schema
from django.utils import timezone
from datetime import timedelta
from archivebox.api.auth import auth_using_token, auth_using_password, get_or_create_api_token, HeaderTokenAuth, BearerTokenAuth
from api.models import APIToken
from api.auth import auth_using_token, auth_using_password, get_or_create_api_token
router = Router(tags=["Authentication"], auth=None)
router = Router(tags=['Authentication'], auth=None)
class PasswordAuthSchema(Schema):
"""Schema for a /get_api_token request"""
username: str | None = None
password: str | None = None
username: Optional[str] = None
password: Optional[str] = None
@router.post(
"/get_api_token",
auth=None,
summary="Generate an API token for a given username & password (or currently logged-in user)",
) # auth=None because they are not authed yet
def get_api_token(request: HttpRequest, auth_data: PasswordAuthSchema):
@router.post("/get_api_token", auth=None, summary='Generate an API token for a given username & password (or currently logged-in user)') # auth=None because they are not authed yet
def get_api_token(request, auth_data: PasswordAuthSchema):
user = auth_using_password(
username=auth_data.username,
password=auth_data.password,
@@ -32,75 +30,24 @@ def get_api_token(request: HttpRequest, auth_data: PasswordAuthSchema):
if user and user.is_superuser:
api_token = get_or_create_api_token(user)
assert api_token is not None, "Failed to create API token"
return {
"success": True,
"user_id": str(user.pk),
"username": user.username,
"token": api_token.token,
"expires": api_token.expires.isoformat() if api_token.expires else None,
}
return api_token.__json__()
return {"success": False, "errors": ["Invalid credentials"]}
class TokenAuthSchema(Schema):
"""Schema for a /check_api_token request"""
token: str
@router.post(
"/check_api_token",
auth=None,
summary="Validate an API token to make sure its valid and non-expired",
) # auth=None because they are not authed yet
def check_api_token(request: HttpRequest, token_data: TokenAuthSchema):
@router.post("/check_api_token", auth=None, summary='Validate an API token to make sure its valid and non-expired') # auth=None because they are not authed yet
def check_api_token(request, token_data: TokenAuthSchema):
user = auth_using_token(
token=token_data.token,
request=request,
)
if user:
return {"success": True, "user_id": str(user.pk)}
return {"success": False, "user_id": None}
@router.post(
"/browser_session",
auth=[HeaderTokenAuth(), BearerTokenAuth()],
summary="Exchange an administrator API key for a normal browser session",
)
def browser_session(request: HttpRequest):
from django.conf import settings
from django.contrib.auth import login
from django.http import JsonResponse
from django.utils import timezone
from ninja.errors import HttpError
from archivebox.core.routes_util import get_admin_base_url
user = request.auth
if not user.is_active or not user.is_superuser:
raise HttpError(403, "An active administrator is required")
login(request, user, backend="django.contrib.auth.backends.ModelBackend")
# A browser session must not outlive the key used to establish it.
token = request._api_token
lifetime = settings.SESSION_COOKIE_AGE
if token.expires:
lifetime = min(lifetime, max(0, int((token.expires - timezone.now()).total_seconds())))
request.session.set_expiry(lifetime)
request.session.save()
response = JsonResponse(
{
"admin_url": get_admin_base_url(request=request).rstrip("/") + "/admin/",
"cookie": {
"name": settings.SESSION_COOKIE_NAME,
"value": request.session.session_key,
"expires": request.session.get_expiry_date().timestamp(),
"secure": settings.SESSION_COOKIE_SECURE,
},
},
)
# The native client installs a host-only HttpOnly cookie on the admin origin;
# API and admin hosts can differ, so a Set-Cookie on the API host is insufficient.
response["Cache-Control"] = "no-store"
return response
+159 -252
View File
@@ -1,329 +1,236 @@
__package__ = "archivebox.api"
__package__ = 'archivebox.api'
import json
from io import StringIO
from typing import Any
from typing import List, Dict, Any, Optional
from enum import Enum
from django.http import HttpRequest
from ninja import Router, Schema
from ninja.errors import HttpError
from pydantic import Field
from ..main import (
add,
remove,
update,
list_all,
schedule,
)
from archivebox.misc.util import ansi_to_html
from archivebox.core.models import SnapshotQuerySet
from archivebox.config.common import ARCHIVING_CONFIG
from .auth import API_AUTH_METHODS
# router for API that exposes archivebox cli subcommands as REST endpoints
router = Router(tags=["ArchiveBox CLI Sub-Commands"])
router = Router(tags=['ArchiveBox CLI Sub-Commands'], auth=API_AUTH_METHODS)
# Schemas
JSONType = list[Any] | dict[str, Any] | bool | int | str | None
FILTER_PATTERNS_EXAMPLES = [["https://example.com"]]
JSONType = List[Any] | Dict[str, Any] | bool | int | str | None
class CLICommandResponseSchema(Schema):
success: bool
errors: list[str]
errors: List[str]
result: JSONType
result_format: str = "str"
stdout: str
stderr: str
class FilterTypeChoices(str, Enum):
exact = 'exact'
substring = 'substring'
regex = 'regex'
domain = 'domain'
tag = 'tag'
timestamp = 'timestamp'
FilterTypeChoices = Enum(
"FilterTypeChoices",
{filter_type: filter_type for filter_type in SnapshotQuerySet.FILTER_TYPE_CHOICES},
type=str,
)
class StatusChoices(str, Enum):
indexed = 'indexed'
archived = 'archived'
unarchived = 'unarchived'
present = 'present'
valid = 'valid'
invalid = 'invalid'
duplicate = 'duplicate'
orphaned = 'orphaned'
corrupted = 'corrupted'
unrecognized = 'unrecognized'
class AddCommandSchema(Schema):
urls: list[str]
snapshot_ids: list[str] | None = None
urls: List[str]
tag: str = ""
depth: int = 0
max_urls: int = 0
crawl_max_size: int = 0
crawl_timeout: int = 0
snapshot_max_size: int = 0
parser: str = "auto"
plugins: str = ""
persona: str = "Default"
only_new: bool | None = None
update: bool = False
update: bool = not ARCHIVING_CONFIG.ONLY_NEW # Default to the opposite of ARCHIVING_CONFIG.ONLY_NEW
update_all: bool = False
index_only: bool = False
overwrite: bool = False
init: bool = False
extractors: str = ""
parser: str = "auto"
class UpdateCommandSchema(Schema):
resume: Optional[float] = 0
only_new: bool = ARCHIVING_CONFIG.ONLY_NEW
index_only: bool = False
class SnapshotFilterCommandSchema(Schema):
after: float | None = 0
before: float | None = None
filter_type: str | None = FilterTypeChoices.substring
filter_patterns: list[str] | None = Field(default=None, examples=FILTER_PATTERNS_EXAMPLES)
status: str | None = None
url__icontains: str | None = None
url__istartswith: str | None = None
tag: str | None = None
crawl_id: str | None = None
limit: int | None = None
sort: str | None = None
search: str | None = None
class UpdateCommandSchema(SnapshotFilterCommandSchema):
resume: str | None = None
batch_size: int = 100
continuous: bool = False
index_only: bool = False
migrate_only: bool = False
overwrite: bool = False
after: Optional[float] = 0
before: Optional[float] = 999999999999999
status: Optional[StatusChoices] = StatusChoices.unarchived
filter_type: Optional[str] = FilterTypeChoices.substring
filter_patterns: Optional[List[str]] = ['https://example.com']
extractors: Optional[str] = ""
class ScheduleCommandSchema(Schema):
import_path: str | None = None
import_path: Optional[str] = None
add: bool = False
show: bool = False
foreground: bool = False
run_all: bool = False
quiet: bool = False
every: str | None = None
tag: str = ""
every: Optional[str] = None
tag: str = ''
depth: int = 0
only_new: bool | None = None
update: bool = False
overwrite: bool = False
update: bool = not ARCHIVING_CONFIG.ONLY_NEW
clear: bool = False
class ListCommandSchema(SnapshotFilterCommandSchema):
class ListCommandSchema(Schema):
filter_patterns: Optional[List[str]] = ['https://example.com']
filter_type: str = FilterTypeChoices.substring
status: Optional[StatusChoices] = StatusChoices.indexed
after: Optional[float] = 0
before: Optional[float] = 999999999999999
sort: str = 'bookmarked_at'
as_json: bool = True
as_html: bool = False
as_csv: str | None = "timestamp,url"
as_csv: str | bool = 'timestamp,url'
with_headers: bool = False
class RemoveCommandSchema(SnapshotFilterCommandSchema):
class RemoveCommandSchema(Schema):
delete: bool = True
after: Optional[float] = 0
before: Optional[float] = 999999999999999
filter_type: str = FilterTypeChoices.exact
timeout: float = 60.0
filter_patterns: Optional[List[str]] = ['https://example.com']
def snapshot_filter_kwargs(args: SnapshotFilterCommandSchema, *, default_filter_type: str) -> dict[str, Any]:
kwargs = args.dict()
kwargs["filter_patterns"] = kwargs.get("filter_patterns") or []
kwargs["filter_type"] = kwargs.get("filter_type") or default_filter_type
return kwargs
@router.post("/add", response=CLICommandResponseSchema, summary="archivebox add [args] [urls]")
def cli_add(request: HttpRequest, args: AddCommandSchema):
from archivebox.cli.archivebox_add import add
from archivebox.misc.util import validate_url
config_overrides: dict[str, object] = {}
if args.only_new is not None:
config_overrides["ONLY_NEW"] = bool(args.only_new)
if args.update or args.overwrite:
config_overrides["ONLY_NEW"] = False
submitted_urls: str | list[str] = args.urls
if len(args.urls) == 1:
try:
validate_url(args.urls[0])
except ValueError:
submitted_urls = args.urls[0]
else:
submitted_urls = []
for url in args.urls:
try:
submitted_urls.append(validate_url(url))
except ValueError:
continue
if not submitted_urls:
raise HttpError(400, "No valid URLs were submitted")
crawl, snapshots = add(
urls=submitted_urls,
snapshot_ids=args.snapshot_ids,
@router.post("/add", response=CLICommandResponseSchema, summary='archivebox add [args] [urls]')
def cli_add(request, args: AddCommandSchema):
result = add(
urls=args.urls,
tag=args.tag,
depth=args.depth,
max_urls=args.max_urls,
crawl_max_size=args.crawl_max_size,
crawl_timeout=args.crawl_timeout,
snapshot_max_size=args.snapshot_max_size,
update=args.update,
update_all=args.update_all,
index_only=args.index_only,
plugins=args.plugins,
persona=args.persona,
overwrite=args.overwrite,
init=args.init,
extractors=args.extractors,
parser=args.parser,
bg=True, # Always run in background for API calls
created_by_id=request.user.pk,
config=config_overrides or None,
)
snapshot_ids = [str(snapshot_id) for snapshot_id in snapshots.values_list("id", flat=True)]
result_payload = {
"crawl_id": str(crawl.id),
"num_snapshots": len(snapshot_ids),
"snapshot_ids": snapshot_ids,
"queued_urls": args.urls if isinstance(submitted_urls, str) else submitted_urls,
}
stdout = request.__dict__.get("stdout")
stderr = request.__dict__.get("stderr")
return {
"success": True,
"errors": [],
"result": result_payload,
"result_format": "json",
"stdout": ansi_to_html(stdout.getvalue().strip()) if isinstance(stdout, StringIO) else "",
"stderr": ansi_to_html(stderr.getvalue().strip()) if isinstance(stderr, StringIO) else "",
}
@router.post("/update", response=CLICommandResponseSchema, summary="archivebox update [args] [filter_patterns]")
def cli_update(request: HttpRequest, args: UpdateCommandSchema):
from archivebox.cli.archivebox_update import _build_filtered_snapshots_queryset, update
from archivebox.core.snapshot_status import normalize_snapshot_status
try:
status = normalize_snapshot_status(args.status)
except ValueError as err:
raise HttpError(400, str(err)) from err
update_kwargs = snapshot_filter_kwargs(args, default_filter_type=FilterTypeChoices.substring)
update_kwargs["status"] = status
update_kwargs["stop_daemon_stack"] = False
is_filtered_update = any(
(update_kwargs.get(key) for key in (*SnapshotQuerySet.FILTER_ARG_KEYS, "resume") if key != "filter_type"),
)
matched_snapshot_ids = []
if is_filtered_update:
matched_snapshot_ids = [
str(snapshot_id) for snapshot_id in _build_filtered_snapshots_queryset(**update_kwargs).values_list("id", flat=True)
]
update(**update_kwargs)
stdout = request.__dict__.get("stdout")
stderr = request.__dict__.get("stderr")
return {
"success": True,
"errors": [],
"result": {
"matched_count": len(matched_snapshot_ids),
"snapshot_ids": matched_snapshot_ids,
}
if is_filtered_update
else None,
"stdout": ansi_to_html(stdout.getvalue().strip()) if isinstance(stdout, StringIO) else "",
"stderr": ansi_to_html(stderr.getvalue().strip()) if isinstance(stderr, StringIO) else "",
}
@router.post("/schedule", response=CLICommandResponseSchema, summary="archivebox schedule [args] [import_path]")
def cli_schedule(request: HttpRequest, args: ScheduleCommandSchema):
from archivebox.cli.archivebox_schedule import schedule
config_overrides: dict[str, object] = {}
if args.only_new is not None:
config_overrides["ONLY_NEW"] = bool(args.only_new)
if args.update or args.overwrite:
config_overrides["ONLY_NEW"] = False
result = schedule(
import_path=args.import_path,
add=args.add,
show=args.show,
foreground=args.foreground,
run_all=args.run_all,
quiet=args.quiet,
clear=args.clear,
every=args.every,
tag=args.tag,
depth=args.depth,
config=config_overrides or None,
)
stdout = request.__dict__.get("stdout")
stderr = request.__dict__.get("stderr")
return {
"success": True,
"errors": [],
"result": result,
"result_format": "json",
"stdout": ansi_to_html(stdout.getvalue().strip()) if isinstance(stdout, StringIO) else "",
"stderr": ansi_to_html(stderr.getvalue().strip()) if isinstance(stderr, StringIO) else "",
"stdout": ansi_to_html(request.stdout.getvalue().strip()),
"stderr": ansi_to_html(request.stderr.getvalue().strip()),
}
@router.post("/search", response=CLICommandResponseSchema, summary="archivebox search [args] [filter_patterns]")
def cli_search(request: HttpRequest, args: ListCommandSchema):
from archivebox.cli.archivebox_snapshot import build_snapshot_queryset
@router.post("/update", response=CLICommandResponseSchema, summary='archivebox update [args] [filter_patterns]')
def cli_update(request, args: UpdateCommandSchema):
result = update(
resume=args.resume,
only_new=args.only_new,
index_only=args.index_only,
overwrite=args.overwrite,
before=args.before,
after=args.after,
status=args.status,
filter_type=args.filter_type,
filter_patterns=args.filter_patterns,
extractors=args.extractors,
)
return {
"success": True,
"errors": [],
"result": result,
"stdout": ansi_to_html(request.stdout.getvalue().strip()),
"stderr": ansi_to_html(request.stderr.getvalue().strip()),
}
search_kwargs = snapshot_filter_kwargs(args, default_filter_type=FilterTypeChoices.substring)
as_json = search_kwargs.pop("as_json")
as_html = search_kwargs.pop("as_html")
as_csv = search_kwargs.pop("as_csv")
with_headers = search_kwargs.pop("with_headers")
try:
snapshots = build_snapshot_queryset(**search_kwargs).select_related("crawl", "crawl__created_by")
except ValueError as err:
raise HttpError(400, str(err)) from err
result_format = "txt"
if as_json:
@router.post("/schedule", response=CLICommandResponseSchema, summary='archivebox schedule [args] [import_path]')
def cli_schedule(request, args: ScheduleCommandSchema):
result = schedule(
import_path=args.import_path,
add=args.add,
show=args.show,
clear=args.clear,
every=args.every,
tag=args.tag,
depth=args.depth,
overwrite=args.overwrite,
update=args.update,
)
return {
"success": True,
"errors": [],
"result": result,
"stdout": ansi_to_html(request.stdout.getvalue().strip()),
"stderr": ansi_to_html(request.stderr.getvalue().strip()),
}
@router.post("/list", response=CLICommandResponseSchema, summary='archivebox list [args] [filter_patterns]')
def cli_list(request, args: ListCommandSchema):
result = list_all(
filter_patterns=args.filter_patterns,
filter_type=args.filter_type,
status=args.status,
after=args.after,
before=args.before,
sort=args.sort,
csv=args.as_csv,
json=args.as_json,
html=args.as_html,
with_headers=args.with_headers,
)
result_format = 'txt'
if args.as_json:
result_format = "json"
result = [
json.loads(json.dumps(snapshot.to_dict(extended=True), default=str))
for snapshot in snapshots.prefetch_related("tags").iterator(chunk_size=500)
]
elif as_html:
elif args.as_html:
result_format = "html"
result = "\n".join(snapshot.url for snapshot in snapshots.iterator(chunk_size=500))
elif as_csv:
elif args.as_csv:
result_format = "csv"
cols = [col.strip() for col in as_csv.split(",") if col.strip()]
rows = [snapshot.to_csv(cols=cols, separator=",") for snapshot in snapshots.prefetch_related("tags").iterator(chunk_size=500)]
result = "\n".join((",".join(cols), *rows) if with_headers else rows)
else:
result = "\n".join(snapshot.url for snapshot in snapshots.iterator(chunk_size=500))
stdout = request.__dict__.get("stdout")
stderr = request.__dict__.get("stderr")
return {
"success": True,
"errors": [],
"result": result,
"result_format": result_format,
"stdout": ansi_to_html(stdout.getvalue().strip()) if isinstance(stdout, StringIO) else "",
"stderr": ansi_to_html(stderr.getvalue().strip()) if isinstance(stderr, StringIO) else "",
"stdout": ansi_to_html(request.stdout.getvalue().strip()),
"stderr": ansi_to_html(request.stderr.getvalue().strip()),
}
@router.post("/remove", response=CLICommandResponseSchema, summary="archivebox remove [args] [filter_patterns]")
def cli_remove(request: HttpRequest, args: RemoveCommandSchema):
from archivebox.cli.archivebox_remove import remove
from archivebox.core.models import Snapshot
remove_kwargs = snapshot_filter_kwargs(args, default_filter_type=FilterTypeChoices.exact)
timeout_arg = remove_kwargs.pop("timeout")
timeout = min(float(timeout_arg if timeout_arg is not None else 60.0), 60.0)
try:
snapshots_to_remove = Snapshot.objects.order_by("-created_at").search(**remove_kwargs)
except ValueError as err:
raise HttpError(400, str(err)) from err
@router.post("/remove", response=CLICommandResponseSchema, summary='archivebox remove [args] [filter_patterns]')
def cli_remove(request, args: RemoveCommandSchema):
result = remove(
yes=True, # no way to interactively ask for confirmation via API, so we force yes
snapshots=snapshots_to_remove,
timeout=timeout,
yes=True, # no way to interactively ask for confirmation via API, so we force yes
delete=args.delete,
before=args.before,
after=args.after,
filter_type=args.filter_type,
filter_patterns=args.filter_patterns,
)
stdout = request.__dict__.get("stdout")
stderr = request.__dict__.get("stderr")
return {
"success": bool(result["success"]),
"errors": [str(result["error"])] if result["error"] else [],
"success": True,
"errors": [],
"result": result,
"result_format": "json",
"stdout": ansi_to_html(stdout.getvalue().strip()) if isinstance(stdout, StringIO) else "",
"stderr": ansi_to_html(stderr.getvalue().strip()) if isinstance(stderr, StringIO) else "",
"stdout": ansi_to_html(request.stdout.getvalue().strip()),
"stderr": ansi_to_html(request.stderr.getvalue().strip()),
}
+309 -1271
View File
File diff suppressed because it is too large. Load diff
-270
View File
@@ -1,270 +0,0 @@
__package__ = "archivebox.api"
from pathlib import Path
from uuid import UUID
from datetime import datetime
from django.http import FileResponse, HttpRequest
from django.shortcuts import redirect
from django.utils import timezone
from django.contrib.auth import get_user_model
from django.contrib.auth.models import User
from ninja import Router, Schema
from ninja.errors import HttpError
from archivebox.core.models import Snapshot
from archivebox.core.permissions import (
PERMISSIONS_PUBLIC,
PERMISSIONS_UNLISTED,
is_admin_user,
normalize_permissions,
)
from archivebox.config.common import get_config
from archivebox.crawls.models import Crawl
from archivebox.misc.util import filter_queryset_by_uuid_substring, validate_url
from .auth import API_AUTH_METHODS, authenticated_user_from_request
router = Router(tags=["Crawl Models"], auth=API_AUTH_METHODS)
class CrawlSchema(Schema):
TYPE: str = "crawls.models.Crawl"
id: UUID
modified_at: datetime
created_at: datetime
created_by_id: str
created_by_username: str
status: str
retry_at: datetime | None
is_paused: bool
urls: str
max_depth: int
tags_str: str
config: dict
@staticmethod
def resolve_created_by_id(obj):
return str(obj.created_by_id)
@staticmethod
def resolve_created_by_username(obj):
user_model = get_user_model()
user = user_model.objects.get(id=obj.created_by_id)
username = user.username
return username if isinstance(username, str) else str(user)
@staticmethod
def resolve_config(obj):
# Redact credential values so REST responses can never leak the raw
# token/secret/api-key that the operator stored in Crawl.config.
from archivebox.config.common import redact_sensitive_config
return redact_sensitive_config(obj.config)
@staticmethod
def resolve_snapshots(obj, context):
if bool(context["request"].__dict__.get("with_snapshots", False)):
return obj.snapshot_set.all().distinct()
return Snapshot.objects.none()
class CrawlUpdateSchema(Schema):
action: str | None = None
status: str | None = None
retry_at: datetime | None = None
tags: list[str] | None = None
tags_str: str | None = None
class CrawlCreateSchema(Schema):
urls: list[str]
max_depth: int = 0
tags: list[str] | None = None
tags_str: str = ""
label: str = ""
notes: str = ""
config: dict = {}
class CrawlDeleteResponseSchema(Schema):
success: bool
crawl_id: str
deleted_count: int
deleted_snapshots: int
def normalize_tag_list(tags: list[str] | None = None, tags_str: str = "") -> list[str]:
if tags is not None:
return [tag.strip() for tag in tags if tag and tag.strip()]
return [tag.strip() for tag in tags_str.split(",") if tag.strip()]
@router.get("/crawls", response=list[CrawlSchema], url_name="get_crawls")
def get_crawls(request: HttpRequest):
return Crawl.objects.all().distinct()
@router.post("/crawls", response=CrawlSchema, url_name="create_crawl")
def create_crawl(request: HttpRequest, data: CrawlCreateSchema):
try:
urls = [validate_url(url) for url in data.urls if url and url.strip()]
except ValueError as err:
raise HttpError(400, str(err)) from err
if not urls:
raise HttpError(400, "At least one URL is required")
if data.max_depth not in (0, 1, 2, 3, 4):
raise HttpError(400, "max_depth must be between 0 and 4")
tags = normalize_tag_list(data.tags, data.tags_str)
config = dict(data.config or {})
config.setdefault("PARSER", "url_list")
config.setdefault("PERMISSIONS", str(get_config().PERMISSIONS))
crawl = Crawl.objects.create(
urls="\n".join(urls),
max_depth=data.max_depth,
tags_str=",".join(tags),
label=data.label,
notes=data.notes,
config=config,
status=Crawl.StatusChoices.QUEUED,
retry_at=timezone.now(),
created_by=request.user if isinstance(request.user, User) else None,
)
return crawl
def get_crawl_by_ref(crawl_id: str):
return filter_queryset_by_uuid_substring(Crawl.objects.all(), crawl_id).get()
@router.get("/crawl/{crawl_id}", response=CrawlSchema, url_name="get_crawl")
def get_crawl(request: HttpRequest, crawl_id: str, as_rss: bool = False, with_snapshots: bool = False, with_archiveresults: bool = False):
"""Get a specific Crawl by id."""
setattr(request, "with_snapshots", with_snapshots)
setattr(request, "with_archiveresults", with_archiveresults)
crawl = get_crawl_by_ref(crawl_id)
if crawl and as_rss:
query = request.GET.copy()
query.pop("as_rss", None)
query["crawl_id"] = str(crawl.id)
return redirect(f"/api/v1/core/snapshots.rss?{query.urlencode()}")
return crawl
def crawl_file(request: HttpRequest, crawl_id: str, path: str):
# Try to resolve the crawl first; if it doesn't exist, return 404.
try:
crawl = get_crawl_by_ref(crawl_id)
except Crawl.DoesNotExist:
raise HttpError(404, "Crawl not found")
user = authenticated_user_from_request(request)
# Gate access using the same model as SnapshotView/can_view_snapshot:
# admins always pass; owners can see their own crawls; otherwise the crawl
# must be PUBLIC or UNLISTED. Don't disclose existence of private crawls.
if not is_admin_user(request):
permissions = normalize_permissions(crawl.permissions)
is_owner = bool(user and crawl.created_by_id == user.id)
if not is_owner and permissions not in {PERMISSIONS_PUBLIC, PERMISSIONS_UNLISTED}:
raise HttpError(404, "Crawl not found")
crawl_root = Path(crawl.output_dir).resolve()
file_path = (crawl_root / path).resolve()
if not file_path.is_file() or crawl_root not in file_path.parents:
raise HttpError(404, "Crawl file not found")
response = FileResponse(file_path.open("rb"))
response["Cache-Control"] = "no-store, no-cache, max-age=0, must-revalidate"
response["Pragma"] = "no-cache"
response["Expires"] = "0"
response["X-Content-Type-Options"] = "nosniff"
return response
@router.get("/crawl/{crawl_id}/files/{filename}", auth=None, url_name="crawl_file_root")
def crawl_file_root(request: HttpRequest, crawl_id: str, filename: str):
return crawl_file(request, crawl_id, filename)
@router.get("/crawl/{crawl_id}/files/{folder}/{filename}", auth=None, url_name="crawl_file_nested_1")
def crawl_file_nested_1(request: HttpRequest, crawl_id: str, folder: str, filename: str):
return crawl_file(request, crawl_id, f"{folder}/{filename}")
@router.get("/crawl/{crawl_id}/files/{folder}/{subfolder}/{filename}", auth=None, url_name="crawl_file_nested_2")
def crawl_file_nested_2(request: HttpRequest, crawl_id: str, folder: str, subfolder: str, filename: str):
return crawl_file(request, crawl_id, f"{folder}/{subfolder}/{filename}")
@router.patch("/crawl/{crawl_id}", response=CrawlSchema, url_name="patch_crawl")
def patch_crawl(request: HttpRequest, crawl_id: str, data: CrawlUpdateSchema):
"""Update a crawl (e.g., set status=sealed to cancel queued work)."""
crawl = get_crawl_by_ref(crawl_id)
payload = data.dict(exclude_unset=True)
update_fields = ["modified_at"]
action = payload.pop("action", None)
if action:
if action == "pause":
crawl.pause()
return crawl
if action in ("resume", "unpause"):
crawl.resume()
return crawl
if action == "cancel":
crawl.cancel()
return crawl
raise HttpError(400, f"Invalid action: {action}")
tags = payload.pop("tags", None)
tags_str = payload.pop("tags_str", None)
if tags is not None or tags_str is not None:
crawl.tags_str = ",".join(normalize_tag_list(tags, tags_str or ""))
update_fields.append("tags_str")
if "status" in payload:
if payload["status"] not in Crawl.StatusChoices.values:
raise HttpError(400, f"Invalid status: {payload['status']}")
if payload["status"] == Crawl.StatusChoices.SEALED:
crawl.cancel()
return crawl
crawl.status = payload["status"]
update_fields.append("status")
if "retry_at" in payload:
crawl.retry_at = payload["retry_at"]
update_fields.append("retry_at")
crawl.save(update_fields=update_fields)
return crawl
@router.delete("/crawl/{crawl_id}", response=CrawlDeleteResponseSchema, url_name="delete_crawl")
def delete_crawl(request: HttpRequest, crawl_id: str):
crawl = get_crawl_by_ref(crawl_id)
crawl_id_str = str(crawl.id)
crawl.cancel()
from archivebox.crawls.locks import crawl_lifecycle_lock
from archivebox.services.runner import run_pending_crawls
with crawl_lifecycle_lock(crawl_id_str):
run_pending_crawls(crawl_id=crawl_id_str, daemon=False)
crawl = get_crawl_by_ref(crawl_id_str)
snapshot_count = crawl.snapshot_set.count()
deleted_count, _ = crawl.delete()
return {
"success": True,
"crawl_id": crawl_id_str,
"deleted_count": deleted_count,
"deleted_snapshots": snapshot_count,
}
-161
View File
@@ -1,161 +0,0 @@
__package__ = "archivebox.api"
from uuid import UUID
from typing import Annotated
from datetime import datetime
from django.http import HttpRequest
from ninja import FilterLookup, FilterSchema, Query, Router, Schema
from ninja.pagination import paginate
from archivebox.api.v1_core import CustomPagination
router = Router(tags=["Machine and Dependencies"])
# ============================================================================
# Machine Schemas
# ============================================================================
class MachineSchema(Schema):
"""Schema for Machine model."""
TYPE: str = "machine.Machine"
id: UUID
created_at: datetime
modified_at: datetime
guid: str
hostname: str
hw_in_docker: bool
hw_in_vm: bool
hw_manufacturer: str
hw_product: str
hw_uuid: str
os_arch: str
os_family: str
os_platform: str
os_release: str
os_kernel: str
stats: dict
num_uses_succeeded: int
num_uses_failed: int
class MachineFilterSchema(FilterSchema):
id: Annotated[str | None, FilterLookup("id__startswith")] = None
hostname: Annotated[str | None, FilterLookup("hostname__icontains")] = None
os_platform: Annotated[str | None, FilterLookup("os_platform__icontains")] = None
os_arch: Annotated[str | None, FilterLookup("os_arch")] = None
hw_in_docker: Annotated[bool | None, FilterLookup("hw_in_docker")] = None
hw_in_vm: Annotated[bool | None, FilterLookup("hw_in_vm")] = None
bin_providers: Annotated[str | None, FilterLookup("bin_providers__icontains")] = None
# ============================================================================
# Binary Schemas
# ============================================================================
class BinarySchema(Schema):
"""Schema for Binary model."""
TYPE: str = "machine.Binary"
id: UUID
created_at: datetime
modified_at: datetime
machine_id: UUID
machine_hostname: str
name: str
binproviders: str
binprovider: str
abspath: str
version: str
sha256: str
status: str
is_valid: bool
num_uses_succeeded: int
num_uses_failed: int
@staticmethod
def resolve_machine_hostname(obj) -> str:
return obj.machine.hostname
@staticmethod
def resolve_is_valid(obj) -> bool:
return obj.is_valid
class BinaryFilterSchema(FilterSchema):
id: Annotated[str | None, FilterLookup("id__startswith")] = None
name: Annotated[str | None, FilterLookup("name__icontains")] = None
binprovider: Annotated[str | None, FilterLookup("binprovider")] = None
status: Annotated[str | None, FilterLookup("status")] = None
machine_id: Annotated[str | None, FilterLookup("machine_id__startswith")] = None
version: Annotated[str | None, FilterLookup("version__icontains")] = None
# ============================================================================
# Machine Endpoints
# ============================================================================
@router.get("/machines", response=list[MachineSchema], url_name="get_machines")
@paginate(CustomPagination)
def get_machines(request: HttpRequest, filters: Query[MachineFilterSchema]):
"""List all machines."""
from archivebox.machine.models import Machine
return filters.filter(Machine.objects.all()).distinct()
@router.get("/machine/current", response=MachineSchema, url_name="get_current_machine")
def get_current_machine(request: HttpRequest):
"""Get the current machine."""
from archivebox.machine.models import Machine
return Machine.current()
@router.get("/machine/{machine_id}", response=MachineSchema, url_name="get_machine")
def get_machine(request: HttpRequest, machine_id: str):
"""Get a specific machine by ID."""
from archivebox.machine.models import Machine
from django.db.models import Q
return Machine.objects.get(Q(id__startswith=machine_id) | Q(hostname__iexact=machine_id))
# ============================================================================
# ============================================================================
# Binary Endpoints
# ============================================================================
@router.get("/binaries", response=list[BinarySchema], url_name="get_binaries")
@paginate(CustomPagination)
def get_binaries(request: HttpRequest, filters: Query[BinaryFilterSchema]):
"""List all binaries."""
from archivebox.machine.models import Binary
return filters.filter(Binary.objects.all().select_related("machine")).distinct()
@router.get("/binary/{binary_id}", response=BinarySchema, url_name="get_binary")
def get_binary(request: HttpRequest, binary_id: str):
"""Get a specific binary by ID."""
from archivebox.machine.models import Binary
return Binary.objects.select_related("machine").get(id__startswith=binary_id)
@router.get("/binary/by-name/{name}", response=list[BinarySchema], url_name="get_binaries_by_name")
def get_binaries_by_name(request: HttpRequest, name: str):
"""Get all binaries with the given name."""
from archivebox.machine.models import Binary
return list(Binary.objects.filter(name__iexact=name).select_related("machine"))
-168
View File
@@ -1,168 +0,0 @@
__package__ = "archivebox.api"
import json
from datetime import datetime
from typing import Any
from uuid import UUID
from django.db.models import Q
from django.http import HttpRequest
from ninja import Router, Schema
from ninja.pagination import paginate
from pydantic import Field
from archivebox.api.v1_core import CustomPagination
from archivebox.personas.importers import validate_persona_name
from archivebox.personas.models import Persona
router = Router(tags=["Personas"])
class PersonaBrowserSettingsSchema(Schema):
user_agent: str = ""
viewport_size: str = ""
viewport_device_scale_factor: float | None = None
language: str = ""
timezone: str = ""
geolocation: dict[str, Any] | None = None
class PersonaSyncSchema(Schema):
extension_persona_id: str
name: str
settings: PersonaBrowserSettingsSchema = Field(default_factory=PersonaBrowserSettingsSchema)
cookies_txt: str = ""
auth_json: dict[str, Any] = Field(default_factory=dict)
class PersonaSchema(Schema):
TYPE: str = "personas.models.Persona"
id: UUID
name: str
created_at: datetime
created_by_id: str
created_by_username: str
config: dict[str, Any] | None
@staticmethod
def resolve_created_by_id(obj):
return str(obj.created_by.pk)
@staticmethod
def resolve_created_by_username(obj) -> str:
return obj.created_by.username
@staticmethod
def resolve_config(obj):
# Redact credential values so REST responses don't leak the raw
# token/secret/api-key the operator stored in Persona.config.
from archivebox.config.common import redact_sensitive_config
return redact_sensitive_config(obj.config)
class PersonaSyncResponseSchema(Schema):
success: bool
created: bool
persona: PersonaSchema
cookies_file_written: bool
auth_file_written: bool
def browser_settings_to_config(extension_persona_id: str, settings: PersonaBrowserSettingsSchema) -> dict[str, Any]:
config: dict[str, Any] = {
"BROWSER_EXTENSION_PERSONA_ID": extension_persona_id,
"BROWSER_EXTENSION_SYNCED_AT": datetime.utcnow().isoformat() + "Z",
}
if settings.user_agent:
config.update(
{
"USER_AGENT": settings.user_agent,
"CHROME_USER_AGENT": settings.user_agent,
"WGET_USER_AGENT": settings.user_agent,
"CURL_USER_AGENT": settings.user_agent,
},
)
if settings.viewport_size:
config.update(
{
"RESOLUTION": settings.viewport_size,
"CHROME_RESOLUTION": settings.viewport_size,
},
)
if settings.viewport_device_scale_factor is not None:
config["BROWSER_DEVICE_SCALE_FACTOR"] = settings.viewport_device_scale_factor
if settings.language:
config["BROWSER_LANGUAGE"] = settings.language
if settings.timezone:
config["BROWSER_TIMEZONE"] = settings.timezone
if settings.geolocation:
config["BROWSER_GEOLOCATION"] = settings.geolocation
return config
def find_persona(extension_persona_id: str, name: str) -> Persona | None:
return (
Persona.objects.filter(
Q(config__BROWSER_EXTENSION_PERSONA_ID=extension_persona_id) | Q(name=name),
)
.order_by("created_at")
.first()
)
@router.get("/personas", response=list[PersonaSchema], url_name="get_personas")
@paginate(CustomPagination)
def get_personas(request: HttpRequest):
"""List personas available on this ArchiveBox server."""
return Persona.objects.all().order_by("name")
@router.post("/sync", response=PersonaSyncResponseSchema, url_name="sync_persona")
def sync_persona(request: HttpRequest, payload: PersonaSyncSchema):
"""
Create or update a Persona from a browser extension profile export.
The extension sends browser settings plus portable auth artifacts. The server
keeps browser override settings in Persona.config and writes cookies.txt /
auth.json into the persona directory for extractors to consume.
"""
name = payload.name.strip()
is_valid, error_message = validate_persona_name(name)
if not is_valid:
raise ValueError(error_message)
persona = find_persona(payload.extension_persona_id, name)
created = persona is None
if persona is None:
persona = Persona(name=name)
if request.user.is_authenticated:
persona.created_by = request.user
persona.config = {
**(persona.config or {}),
**browser_settings_to_config(payload.extension_persona_id, payload.settings),
}
persona.save()
persona.ensure_dirs()
cookies_written = False
if payload.cookies_txt.strip():
(persona.path / "cookies.txt").write_text(payload.cookies_txt)
cookies_written = True
auth_written = False
if payload.auth_json:
(persona.path / "auth.json").write_text(json.dumps(payload.auth_json, indent=2, sort_keys=True) + "\n")
auth_written = True
return {
"success": True,
"created": created,
"persona": persona,
"cookies_file_written": cookies_written,
"auth_file_written": auth_written,
}
-32
View File
@@ -1,32 +0,0 @@
__package__ = "archivebox.api"
from typing import Any
from collections.abc import Callable
import logging
from django.db import transaction
from signal_webhooks.handlers import sync_task_handler
logger = logging.getLogger(__name__)
def warning_error_handler(hook: Any, error: Exception | None) -> None:
if error is not None:
logger.warning("Outbound webhook %r failed: %s", hook.name, error)
return
logger.warning("Outbound webhook %r returned a non-success response.", hook.name)
def transaction_on_commit_task_handler(hook: Callable[..., None], **kwargs: Any) -> None:
def run_webhook() -> None:
try:
sync_task_handler(hook, **kwargs)
except Exception:
logger.warning("Outbound webhook failed after transaction commit.", exc_info=True)
try:
transaction.on_commit(run_webhook)
except Exception:
logger.warning("Could not schedule outbound webhook after transaction commit.", exc_info=True)
-1
View File
@@ -1 +0,0 @@
__package__ = "archivebox.base_models"
-930
View File
@@ -1,930 +0,0 @@
"""Base admin classes for models using UUIDv7."""
__package__ = "archivebox.base_models"
import json
import uuid
from collections.abc import Mapping
from typing import ClassVar, NotRequired, TypedDict, cast
from django import forms
from django.contrib import admin
from django.db import DatabaseError, models
from django.forms.renderers import BaseRenderer
from django.http import HttpRequest, QueryDict
from django.urls import path, register_converter
from django.utils.safestring import SafeString, mark_safe
from django_object_actions import DjangoObjectActions
class HexUUIDConverter:
"""URL path converter that canonicalizes UUIDs to their 32-char hex form.
Accepts both the hyphenated (``aaaaaaaa-bbbb-...``) and bare-hex
(``aaaaaaaabbbb...``) UUID strings on the way in (Django's UUIDField
parses either), but ``to_url`` always emits the bare-hex form. This is
what makes ``reverse("admin:app_model_change", args=[obj.pk])`` produce
``/admin/app/model/06a1a8facb0d.../change/`` instead of the default
hyphenated rendering — admin links throughout the app reverse through
this converter once ``BaseModelAdmin.get_urls`` swaps in
``<hexuuid:object_id>`` below.
"""
regex = r"[0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}"
def to_python(self, value: str) -> str:
# Strip hyphens but stay as a string — Django admin treats the
# captured object_id as a string and calls ``model._meta.pk.to_python``
# itself, so we don't want to short-circuit that.
return value.replace("-", "")
def to_url(self, value) -> str:
if isinstance(value, uuid.UUID):
return value.hex
return str(value).replace("-", "")
register_converter(HexUUIDConverter, "hexuuid")
class ConfigOption(TypedDict):
plugin: str
type: str | list[str]
default: object
description: str
enum: NotRequired[list[object]]
pattern: NotRequired[str]
minimum: NotRequired[int | float]
maximum: NotRequired[int | float]
class KeyValueWidget(forms.Widget):
"""
A widget that renders JSON dict as editable key-value input fields
with + and - buttons to add/remove rows.
Includes autocomplete for available config keys from the plugin system.
"""
template_name = "" # We render manually
class Media:
css: ClassVar[dict[str, list[str]]] = {
"all": [],
}
js: ClassVar[list[str]] = []
def _get_config_options(self) -> dict[str, ConfigOption]:
"""Get available config options from plugins."""
try:
from archivebox.config.common import config_field_metadata
options: dict[str, ConfigOption] = {}
for key, metadata in config_field_metadata().items():
option_type = metadata.get("type", "string")
option: ConfigOption = {
"plugin": str(metadata.get("plugin", "archivebox")),
"type": cast(str | list[str], option_type if isinstance(option_type, (str, list)) else str(option_type)),
"default": metadata.get("default", ""),
"description": str(metadata.get("description", "")),
}
schema = metadata.get("schema")
if isinstance(schema, Mapping):
for schema_key in ("enum", "pattern", "minimum", "maximum"):
if schema_key in schema:
option[schema_key] = schema[schema_key]
options[key] = option
return options
except (ImportError, KeyError, TypeError, ValueError):
return {}
def _parse_value(self, value: object) -> dict[str, object]:
# Parse JSON value to dict
if value is None:
return {}
if isinstance(value, str):
try:
parsed = json.loads(value) if value else {}
except json.JSONDecodeError:
return {}
return parsed if isinstance(parsed, dict) else {}
if isinstance(value, Mapping):
return {str(key): item for key, item in value.items()}
return {}
def render(
self,
name: str,
value: object,
attrs: Mapping[str, str] | None = None,
renderer: BaseRenderer | None = None,
) -> SafeString:
data = self._parse_value(value)
widget_id = attrs.get("id", name) if attrs else name
config_options = self._get_config_options()
# Build datalist options
datalist_options = "\n".join(
f'<option value="{self._escape(key)}">{self._escape(opt["description"][:60] or opt["type"])}</option>'
for key, opt in sorted(config_options.items())
)
# Build config metadata as JSON for JS
config_meta_json = json.dumps(config_options)
html = f'''
<div id="{widget_id}_container" class="key-value-editor" style="width: 100%; max-width: none;">
<datalist id="{widget_id}_keys">
{datalist_options}
</datalist>
<div id="{widget_id}_rows" class="key-value-rows">
'''
# Render existing key-value pairs
for key, val in data.items():
val_str = json.dumps(val) if not isinstance(val, str) else val
html += self._render_row(widget_id, key, val_str)
# Always add one empty row for new entries
html += self._render_row(widget_id, "", "")
html += f'''
</div>
<div style="display: flex; gap: 8px; align-items: center; margin-top: 8px;">
<button type="button" onclick="addKeyValueRow_{widget_id}()"
style="padding: 4px 12px; cursor: pointer; background: #417690; color: white; border: none; border-radius: 4px;">
+ Add Row
</button>
</div>
<input type="hidden" name="{name}" id="{widget_id}" value="">
<script>
(function() {{
var configMeta_{widget_id} = {config_meta_json};
var rowCounter_{widget_id} = 0;
function stringifyValue_{widget_id}(value) {{
return typeof value === 'string' ? value : JSON.stringify(value);
}}
function getTypes_{widget_id}(meta) {{
if (!meta || meta.type === undefined || meta.type === null) {{
return [];
}}
return Array.isArray(meta.type) ? meta.type : [meta.type];
}}
function getMetaForKey_{widget_id}(key) {{
if (!key) {{
return null;
}}
var explicitMeta = configMeta_{widget_id}[key];
if (explicitMeta) {{
return Object.assign({{ key: key }}, explicitMeta);
}}
if (key.endsWith('_BINARY')) {{
return {{
key: key,
plugin: 'custom',
type: 'string',
default: '',
description: 'Path to binary executable',
}};
}}
if (isRegexConfigKey_{widget_id}(key)) {{
return {{
key: key,
plugin: 'custom',
type: 'string',
default: '',
description: 'Regex pattern list',
}};
}}
return null;
}}
function describeMeta_{widget_id}(meta) {{
if (!meta) {{
return '';
}}
var details = '';
if (Array.isArray(meta.enum) && meta.enum.length) {{
details = 'Allowed: ' + meta.enum.map(stringifyValue_{widget_id}).join(', ');
}} else {{
var types = getTypes_{widget_id}(meta);
if (types.length) {{
details = 'Expected: ' + types.join(' or ');
}}
}}
if (meta.minimum !== undefined || meta.maximum !== undefined) {{
var bounds = [];
if (meta.minimum !== undefined) bounds.push('min ' + meta.minimum);
if (meta.maximum !== undefined) bounds.push('max ' + meta.maximum);
details += (details ? ' ' : '') + '(' + bounds.join(', ') + ')';
}}
return [meta.description || '', details].filter(Boolean).join(' ');
}}
function getExampleInput_{widget_id}(key, meta) {{
var types = getTypes_{widget_id}(meta);
if (key.endsWith('_BINARY')) {{
return 'Example: wget or /usr/bin/wget';
}}
if (key.endsWith('_ARGS_EXTRA') || key.endsWith('_ARGS')) {{
return 'Example: ["--extra-arg"]';
}}
if (types.includes('array')) {{
return 'Example: ["value"]';
}}
if (types.includes('object')) {{
return 'Example: {{"key": "value"}}';
}}
return '';
}}
function isRegexConfigKey_{widget_id}(key) {{
return key === 'URL_ALLOWLIST' ||
key === 'URL_DENYLIST' ||
key.endsWith('_PATTERN') ||
key.includes('REGEX');
}}
function isSimpleFilterPattern_{widget_id}(pattern) {{
return /^[\\w.*:-]+$/.test(pattern);
}}
function validateRegexPattern_{widget_id}(pattern) {{
if (!pattern || isSimpleFilterPattern_{widget_id}(pattern)) {{
return '';
}}
try {{
new RegExp(pattern);
}} catch (error) {{
return error && error.message ? error.message : 'Invalid regex';
}}
return '';
}}
function validateRegexConfig_{widget_id}(key, raw, typeName) {{
if (typeName === 'object') {{
var parsed;
try {{
parsed = JSON.parse(raw);
}} catch (error) {{
return {{ ok: false, value: raw, message: 'Must be valid JSON' }};
}}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {{
return {{ ok: false, value: parsed, message: 'Must be a JSON object' }};
}}
for (var regexKey in parsed) {{
var objectRegexError = validateRegexPattern_{widget_id}(regexKey);
if (objectRegexError) {{
return {{ ok: false, value: parsed, message: 'Invalid regex key "' + regexKey + '": ' + objectRegexError }};
}}
}}
return {{ ok: true, value: parsed, message: '' }};
}}
var patterns = raw.split(/[\\n,]+/).map(function(pattern) {{
return pattern.trim();
}}).filter(Boolean);
for (var i = 0; i < patterns.length; i++) {{
var regexError = validateRegexPattern_{widget_id}(patterns[i]);
if (regexError) {{
return {{ ok: false, value: raw, message: 'Invalid regex "' + patterns[i] + '": ' + regexError }};
}}
}}
return {{ ok: true, value: raw, message: '' }};
}}
function validateBinaryValue_{widget_id}(raw) {{
if (!raw) {{
return {{ ok: true, value: raw, message: '' }};
}}
if (/['"`]/.test(raw)) {{
return {{ ok: false, value: raw, message: 'Binary paths cannot contain quotes' }};
}}
if (/[;&|<>$(){{}}\\[\\]!]/.test(raw)) {{
return {{ ok: false, value: raw, message: 'Binary paths can only be a binary name or absolute path' }};
}}
if (raw.startsWith('/')) {{
if (/^[A-Za-z0-9_./+\\- ]+$/.test(raw)) {{
return {{ ok: true, value: raw, message: '' }};
}}
return {{ ok: false, value: raw, message: 'Absolute paths may only contain path-safe characters' }};
}}
if (/^[A-Za-z0-9_.+-]+$/.test(raw)) {{
return {{ ok: true, value: raw, message: '' }};
}}
return {{ ok: false, value: raw, message: 'Enter a binary name like wget or an absolute path like /usr/bin/wget' }};
}}
function parseValue_{widget_id}(raw) {{
try {{
if (raw === 'true') return true;
if (raw === 'false') return false;
if (raw === 'null') return null;
if (raw !== '' && !isNaN(raw)) return Number(raw);
if ((raw.startsWith('{{') && raw.endsWith('}}')) ||
(raw.startsWith('[') && raw.endsWith(']')) ||
(raw.startsWith('"') && raw.endsWith('"'))) {{
return JSON.parse(raw);
}}
}} catch (error) {{
return raw;
}}
return raw;
}}
function sameValue_{widget_id}(left, right) {{
return left === right || JSON.stringify(left) === JSON.stringify(right);
}}
function parseTypedValue_{widget_id}(raw, typeName, meta) {{
var numberValue;
var parsed;
if (typeName && meta && meta.key && isRegexConfigKey_{widget_id}(meta.key)) {{
return validateRegexConfig_{widget_id}(meta.key, raw, typeName);
}}
if (typeName === 'string' && meta && meta.key && meta.key.endsWith('_BINARY')) {{
return validateBinaryValue_{widget_id}(raw);
}}
if (typeName === 'string') {{
if (meta.pattern) {{
try {{
if (!(new RegExp(meta.pattern)).test(raw)) {{
return {{ ok: false, value: raw, message: 'Must match pattern ' + meta.pattern }};
}}
}} catch (error) {{}}
}}
return {{ ok: true, value: raw, message: '' }};
}}
if (typeName === 'integer') {{
if (!/^-?\\d+$/.test(raw)) {{
return {{ ok: false, value: raw, message: 'Must be an integer' }};
}}
numberValue = Number(raw);
if (meta.minimum !== undefined && numberValue < meta.minimum) {{
return {{ ok: false, value: numberValue, message: 'Must be at least ' + meta.minimum }};
}}
if (meta.maximum !== undefined && numberValue > meta.maximum) {{
return {{ ok: false, value: numberValue, message: 'Must be at most ' + meta.maximum }};
}}
return {{ ok: true, value: numberValue, message: '' }};
}}
if (typeName === 'number') {{
if (raw === '' || isNaN(raw)) {{
return {{ ok: false, value: raw, message: 'Must be a number' }};
}}
numberValue = Number(raw);
if (meta.minimum !== undefined && numberValue < meta.minimum) {{
return {{ ok: false, value: numberValue, message: 'Must be at least ' + meta.minimum }};
}}
if (meta.maximum !== undefined && numberValue > meta.maximum) {{
return {{ ok: false, value: numberValue, message: 'Must be at most ' + meta.maximum }};
}}
return {{ ok: true, value: numberValue, message: '' }};
}}
if (typeName === 'boolean') {{
var lowered = raw.toLowerCase();
if (lowered === 'true' || raw === '1') return {{ ok: true, value: true, message: '' }};
if (lowered === 'false' || raw === '0') return {{ ok: true, value: false, message: '' }};
return {{ ok: false, value: raw, message: 'Must be true or false' }};
}}
if (typeName === 'null') {{
return raw === 'null'
? {{ ok: true, value: null, message: '' }}
: {{ ok: false, value: raw, message: 'Must be null' }};
}}
if (typeName === 'array' || typeName === 'object') {{
try {{
parsed = JSON.parse(raw);
}} catch (error) {{
return {{ ok: false, value: raw, message: 'Must be valid JSON' }};
}}
if (typeName === 'array' && Array.isArray(parsed)) {{
return {{ ok: true, value: parsed, message: '' }};
}}
if (typeName === 'object' && parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {{
return {{ ok: true, value: parsed, message: '' }};
}}
return {{
ok: false,
value: parsed,
message: typeName === 'array' ? 'Must be a JSON array' : 'Must be a JSON object',
}};
}}
return {{ ok: true, value: parseValue_{widget_id}(raw), message: '' }};
}}
function validateValueAgainstMeta_{widget_id}(raw, meta) {{
if (!meta || raw === '') {{
return {{ state: 'neutral', value: raw, message: '' }};
}}
var enumValues = Array.isArray(meta.enum) ? meta.enum : [];
var types = getTypes_{widget_id}(meta);
if (!types.length) {{
types = ['string'];
}}
var error = 'Invalid value';
for (var i = 0; i < types.length; i++) {{
var candidate = parseTypedValue_{widget_id}(raw, types[i], meta);
if (!candidate.ok) {{
error = candidate.message || error;
continue;
}}
if (enumValues.length && !enumValues.some(function(enumValue) {{
return sameValue_{widget_id}(enumValue, candidate.value) || stringifyValue_{widget_id}(enumValue) === raw;
}})) {{
error = 'Must be one of: ' + enumValues.map(stringifyValue_{widget_id}).join(', ');
continue;
}}
return {{ state: 'valid', value: candidate.value, message: '' }};
}}
return {{ state: 'invalid', value: raw, message: error }};
}}
function ensureRowId_{widget_id}(row) {{
if (!row.dataset.rowId) {{
row.dataset.rowId = String(rowCounter_{widget_id}++);
}}
return row.dataset.rowId;
}}
function setRowHelp_{widget_id}(row) {{
var keyInput = row.querySelector('.kv-key');
var help = row.querySelector('.kv-help');
if (!keyInput || !help) {{
return;
}}
var key = keyInput.value.trim();
if (!key) {{
help.textContent = '';
return;
}}
var meta = getMetaForKey_{widget_id}(key);
if (meta) {{
var extra = isRegexConfigKey_{widget_id}(key)
? ((meta.type === 'object' || (Array.isArray(meta.type) && meta.type.includes('object')))
? ' Expected: JSON object with regex keys.'
: ' Expected: valid regex.')
: '';
var example = getExampleInput_{widget_id}(key, meta);
help.textContent = [describeMeta_{widget_id}(meta) + extra, example].filter(Boolean).join(' ');
}} else {{
help.textContent = 'Custom key';
}}
}}
function configureValueInput_{widget_id}(row) {{
var keyInput = row.querySelector('.kv-key');
var valueInput = row.querySelector('.kv-value');
var datalist = row.querySelector('.kv-value-options');
if (!keyInput || !valueInput || !datalist) {{
return;
}}
var rowId = ensureRowId_{widget_id}(row);
datalist.id = '{widget_id}_value_options_' + rowId;
var meta = getMetaForKey_{widget_id}(keyInput.value.trim());
var enumValues = Array.isArray(meta && meta.enum) ? meta.enum : [];
var types = getTypes_{widget_id}(meta);
if (!enumValues.length && types.includes('boolean')) {{
enumValues = ['True', 'False'];
}}
if (enumValues.length) {{
datalist.innerHTML = enumValues.map(function(enumValue) {{
return '<option value="' + stringifyValue_{widget_id}(enumValue).replace(/"/g, '&quot;') + '"></option>';
}}).join('');
valueInput.setAttribute('list', datalist.id);
}} else {{
datalist.innerHTML = '';
valueInput.removeAttribute('list');
}}
}}
function setValueValidationState_{widget_id}(input, state, message) {{
if (!input) {{
return;
}}
if (state === 'valid') {{
input.style.borderColor = '#2da44e';
input.style.boxShadow = '0 0 0 1px rgba(45, 164, 78, 0.18)';
input.style.backgroundColor = '#f6ffed';
}} else if (state === 'invalid') {{
input.style.borderColor = '#cf222e';
input.style.boxShadow = '0 0 0 1px rgba(207, 34, 46, 0.18)';
input.style.backgroundColor = '#fff8f8';
}} else {{
input.style.borderColor = '#ccc';
input.style.boxShadow = 'none';
input.style.backgroundColor = '';
}}
input.title = message || '';
}}
function applyValueValidation_{widget_id}(row) {{
var keyInput = row.querySelector('.kv-key');
var valueInput = row.querySelector('.kv-value');
if (!keyInput || !valueInput) {{
return;
}}
var key = keyInput.value.trim();
if (!key) {{
setValueValidationState_{widget_id}(valueInput, 'neutral', '');
return;
}}
var meta = getMetaForKey_{widget_id}(key);
if (!meta) {{
setValueValidationState_{widget_id}(valueInput, 'neutral', '');
return;
}}
var validation = validateValueAgainstMeta_{widget_id}(valueInput.value.trim(), meta);
setValueValidationState_{widget_id}(valueInput, validation.state, validation.message);
}}
function coerceValueForStorage_{widget_id}(key, raw) {{
var meta = getMetaForKey_{widget_id}(key);
if (!meta) {{
return parseValue_{widget_id}(raw);
}}
var validation = validateValueAgainstMeta_{widget_id}(raw, meta);
return validation.state === 'valid' ? validation.value : raw;
}}
function initializeRows_{widget_id}() {{
var container = document.getElementById('{widget_id}_rows');
container.querySelectorAll('.key-value-row').forEach(function(row) {{
ensureRowId_{widget_id}(row);
configureValueInput_{widget_id}(row);
setRowHelp_{widget_id}(row);
applyValueValidation_{widget_id}(row);
}});
}}
function updateHiddenField_{widget_id}() {{
var container = document.getElementById('{widget_id}_rows');
var rows = container.querySelectorAll('.key-value-row');
var result = {{}};
rows.forEach(function(row) {{
var keyInput = row.querySelector('.kv-key');
var valInput = row.querySelector('.kv-value');
if (keyInput && valInput && keyInput.value.trim()) {{
var key = keyInput.value.trim();
var val = valInput.value.trim();
result[key] = coerceValueForStorage_{widget_id}(key, val);
}}
}});
document.getElementById('{widget_id}').value = JSON.stringify(result);
}}
window.addKeyValueRow_{widget_id} = function() {{
var container = document.getElementById('{widget_id}_rows');
var newRow = document.createElement('div');
newRow.className = 'key-value-row';
newRow.style.cssText = 'margin-bottom: 6px;';
newRow.innerHTML = '<div class="kv-inputs" style="display: flex; gap: 8px; align-items: center;">' +
'<input type="text" class="kv-key" placeholder="KEY" list="{widget_id}_keys" ' +
'style="flex: 1; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">' +
'<input type="text" class="kv-value" placeholder="value" ' +
'style="flex: 2; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">' +
'<datalist class="kv-value-options"></datalist>' +
'<button type="button" onclick="removeKeyValueRow_{widget_id}(this)" ' +
'style="padding: 4px 10px; cursor: pointer; background: #ba2121; color: white; border: none; border-radius: 4px; font-weight: bold;">−</button>' +
'</div>' +
'<div class="kv-help" style="margin-top: 4px; font-size: 11px; color: #666; font-style: italic;"></div>';
container.appendChild(newRow);
ensureRowId_{widget_id}(newRow);
configureValueInput_{widget_id}(newRow);
setRowHelp_{widget_id}(newRow);
applyValueValidation_{widget_id}(newRow);
updateHiddenField_{widget_id}();
newRow.querySelector('.kv-key').focus();
}};
window.removeKeyValueRow_{widget_id} = function(btn) {{
var row = btn.closest('.key-value-row');
row.remove();
updateHiddenField_{widget_id}();
}};
window.updateHiddenField_{widget_id} = updateHiddenField_{widget_id};
function configRowForKey_{widget_id}(key) {{
var container = document.getElementById('{widget_id}_rows');
if (!container) {{
return null;
}}
var match = null;
container.querySelectorAll('.key-value-row').forEach(function(row) {{
if (match) {{ return; }}
var keyInput = row.querySelector('.kv-key');
if (keyInput && keyInput.value.trim() === key) {{
match = row;
}}
}});
if (!match) {{
window.addKeyValueRow_{widget_id}();
var rows = container.querySelectorAll('.key-value-row');
match = rows[rows.length - 1];
var keyInput = match.querySelector('.kv-key');
if (keyInput) {{
keyInput.value = key;
keyInput.dispatchEvent(new Event('input', {{ bubbles: true }}));
}}
}}
return match;
}}
function prefillConfigFromQuery_{widget_id}() {{
var params = new URLSearchParams(window.location.search);
var consumedConfigKeys = [];
params.forEach(function(value, key) {{
if (!/^[A-Z][A-Z0-9_]*$/.test(key) || !configMeta_{widget_id}[key]) {{
return;
}}
consumedConfigKeys.push(key);
var match = configRowForKey_{widget_id}(key);
var valueInput = match && match.querySelector('.kv-value');
if (valueInput) {{
valueInput.value = value;
valueInput.dispatchEvent(new Event('input', {{ bubbles: true }}));
}}
}});
updateHiddenField_{widget_id}();
if (consumedConfigKeys.length) {{
var cleanUrl = new URL(window.location.href);
consumedConfigKeys.forEach(function(key) {{ cleanUrl.searchParams.delete(key); }});
window.history.replaceState(null, '', cleanUrl.pathname + cleanUrl.search + cleanUrl.hash);
}}
}}
function focusConfigKeyFromHash_{widget_id}() {{
// Deep-link affordance: ``…/change/#SOME_KEY`` jumps directly
// to (or creates) the matching row in this editor. Used by
// the setup wizard and live-config detail pages.
var hash = (window.location.hash || '').replace(/^#/, '').trim();
if (!hash || !/^[A-Z][A-Z0-9_]*$/.test(hash)) {{
return;
}}
var match = configRowForKey_{widget_id}(hash);
if (!match) {{
return;
}}
match.scrollIntoView({{ behavior: 'smooth', block: 'center' }});
var prevOutline = match.style.outline;
match.style.outline = '2px solid #f59e0b';
match.style.outlineOffset = '2px';
match.style.transition = 'outline 1.2s ease-out';
setTimeout(function() {{
match.style.outline = prevOutline || 'none';
}}, 1400);
var valueInput = match.querySelector('.kv-value');
if (valueInput) {{
valueInput.focus();
try {{ valueInput.setSelectionRange(valueInput.value.length, valueInput.value.length); }} catch (e) {{}}
}}
}}
// Initialize on load
document.addEventListener('DOMContentLoaded', function() {{
initializeRows_{widget_id}();
prefillConfigFromQuery_{widget_id}();
updateHiddenField_{widget_id}();
focusConfigKeyFromHash_{widget_id}();
}});
// Also run immediately in case DOM is already ready
if (document.readyState !== 'loading') {{
initializeRows_{widget_id}();
prefillConfigFromQuery_{widget_id}();
updateHiddenField_{widget_id}();
focusConfigKeyFromHash_{widget_id}();
}}
window.addEventListener('hashchange', focusConfigKeyFromHash_{widget_id});
// Update on any input change
var rowsEl_{widget_id} = document.getElementById('{widget_id}_rows');
rowsEl_{widget_id}.addEventListener('input', function(event) {{
var row = event.target.closest('.key-value-row');
if (!row) {{
return;
}}
if (event.target.classList.contains('kv-key')) {{
configureValueInput_{widget_id}(row);
setRowHelp_{widget_id}(row);
}}
if (event.target.classList.contains('kv-key') || event.target.classList.contains('kv-value')) {{
applyValueValidation_{widget_id}(row);
updateHiddenField_{widget_id}();
}}
}});
}})();
</script>
</div>
'''
return mark_safe(html)
def _render_row(self, widget_id: str, key: str, value: str) -> str:
from archivebox.config.common import is_sensitive_config_key
# Sensitive keys (``*TOKEN*``, ``*SECRET*``, ``*API_KEY*``, ``*APIKEY*``) are
# rendered write-only: the input is a password field with a placeholder
# showing the value is set, but the raw value is NEVER sent to the browser.
# When the user submits the form with the field left blank, the
# ``ConfigEditorMixin.save_model`` hook re-merges the previously-saved
# value so leaving it untouched is a no-op rather than a destructive clear.
is_sensitive = is_sensitive_config_key(key)
has_value = bool(value)
if is_sensitive:
input_type = "password"
rendered_value = ""
placeholder = (
"•••••• (saved — enter new value to replace, clear by deleting row)" if has_value else "value (will be saved write-only)"
)
extra_attrs = ' autocomplete="off" data-sensitive="1"' + (' data-had-value="1"' if has_value else "")
else:
input_type = "text"
rendered_value = self._escape(value)
placeholder = "value"
extra_attrs = ""
return f'''
<div class="key-value-row" style="margin-bottom: 6px;">
<div class="kv-inputs" style="display: flex; gap: 8px; align-items: center;">
<input type="text" class="kv-key" value="{self._escape(key)}" placeholder="KEY" list="{widget_id}_keys"
style="flex: 1; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<input type="{input_type}" class="kv-value" value="{rendered_value}" placeholder="{self._escape(placeholder)}"{extra_attrs}
style="flex: 2; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-family: monospace; font-size: 12px;">
<datalist class="kv-value-options"></datalist>
<button type="button" onclick="removeKeyValueRow_{widget_id}(this)"
style="padding: 4px 10px; cursor: pointer; background: #ba2121; color: white; border: none; border-radius: 4px; font-weight: bold;">−</button>
</div>
<div class="kv-help" style="margin-top: 4px; font-size: 11px; color: #666; font-style: italic;"></div>
</div>
'''
def _escape(self, s: object) -> str:
"""Escape HTML special chars in attribute values."""
if not s:
return ""
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;")
def value_from_datadict(
self,
data: QueryDict | Mapping[str, object],
files: object,
name: str,
) -> str:
value = data.get(name, "{}")
return value if isinstance(value, str) else "{}"
class ConfigEditorMixin(admin.ModelAdmin):
"""
Mixin for admin classes with a config JSON field.
Provides a key-value editor widget with autocomplete for available config keys.
"""
def formfield_for_dbfield(
self,
db_field: models.Field,
request: HttpRequest,
**kwargs: object,
) -> forms.Field | None:
"""Use KeyValueWidget for the config JSON field."""
if db_field.name == "config":
kwargs["widget"] = KeyValueWidget()
return super().formfield_for_dbfield(db_field, request, **kwargs)
def save_model(self, request: HttpRequest, obj, form, change):
"""Preserve write-only redacted credentials on save.
The KeyValueWidget renders sensitive keys (``*TOKEN*``, ``*SECRET*``,
``*API_KEY*``, ``*APIKEY*``) with an empty value + password input —
the real value never leaves the server. On submit, an empty value
for a sensitive key that was previously set means "leave untouched",
not "clear it." We honor that here by re-merging the stored value
before the row is written. Explicitly removing the row in the UI
still clears it (the key is gone from the submitted JSON, so there's
nothing to merge over).
"""
from archivebox.config.common import is_sensitive_config_key
if change and obj.pk and obj.config is not None:
try:
stored = type(obj).objects.filter(pk=obj.pk).values_list("config", flat=True).first() or {}
except (AttributeError, DatabaseError, TypeError, ValueError):
stored = {}
if isinstance(stored, dict):
new_config = dict(obj.config or {})
for key, new_value in list(new_config.items()):
if not is_sensitive_config_key(key):
continue
if new_value not in (None, "") and new_value != "********":
continue
if key in stored:
new_config[key] = stored[key]
obj.config = new_config
super().save_model(request, obj, form, change)
class BaseModelAdmin(DjangoObjectActions, admin.ModelAdmin):
list_display = ("id", "created_at", "created_by")
readonly_fields = ("id", "created_at", "modified_at")
show_search_mode_selector = False
change_form_template = "admin/archivebox_change_form.html"
def get_admin_toolbar_actions(self, request, obj):
"""Return extra action button dicts for the shared change-form toolbar.
See ``templates/admin/includes/archivebox_toolbar.html`` for the
accepted keys. Default: no extras (toolbar renders Save/History/Delete
plus any ``django-object-actions`` change_actions).
"""
return []
def render_change_form(self, request, context, add=False, change=False, form_url="", obj=None):
context.setdefault("archivebox_admin_actions", self.get_admin_toolbar_actions(request, obj))
return super().render_change_form(request, context, add=add, change=change, form_url=form_url, obj=obj)
def get_default_search_mode(self) -> str:
# The shared changelist template always asks every admin for a default
# search mode, even when the search-mode toggle is hidden.
return "meta"
def get_form(
self,
request: HttpRequest,
obj: models.Model | None = None,
change: bool = False,
**kwargs: object,
):
form = super().get_form(request, obj, change=change, **kwargs)
if "created_by" in form.base_fields:
form.base_fields["created_by"].initial = request.user
return form
def get_urls(self):
"""Swap the per-object admin URLs from ``<path:object_id>`` to
``<hexuuid:object_id>`` so canonical change/delete/history URLs use the
32-char hex form. The hyphenated form still resolves because the
converter's regex accepts both — Django reverses through ``to_url``
which always emits hex, so links in templates / changelists / inline
formsets all canonicalize automatically.
Non-UUID PKs (an ``IntegerField`` PK on some legacy table, for example)
won't match the converter's regex and fall back to the default
``<path:object_id>`` patterns we still include after our swap.
"""
info = self.opts.app_label, self.opts.model_name
object_routes = [
path("<hexuuid:object_id>/history/", self.admin_site.admin_view(self.history_view), name="{}_{}_history".format(*info)),
path("<hexuuid:object_id>/delete/", self.admin_site.admin_view(self.delete_view), name="{}_{}_delete".format(*info)),
path("<hexuuid:object_id>/change/", self.admin_site.admin_view(self.change_view), name="{}_{}_change".format(*info)),
]
# Append after super().get_urls() so our patterns are the
# *last-registered* ones with the canonical admin URL names — Django's
# reverse() picks the later registration when names collide, which is
# how we make ``reverse("admin:app_model_change", args=[obj.pk])``
# emit the hex form. The original ``<path:object_id>`` routes stay in
# place as a fallback for non-UUID PKs and for resolving inbound
# hyphenated URLs (the ``hexuuid`` regex accepts both forms anyway).
return super().get_urls() + object_routes
-307
View File
@@ -1,307 +0,0 @@
"""Base models using UUIDv7 for all id fields."""
__package__ = "archivebox.base_models"
import json
import shutil
from typing import Any
from archivebox.uuid_compat import CompactUUIDField, uuid7
from pathlib import Path
from django.db import models
from django.db.models import F
from django.db import transaction
from django.db.models.signals import pre_delete
from django.utils import timezone
from django.contrib.auth import get_user_model
from django.urls import reverse_lazy
from django.conf import settings
from django_stubs_ext.db.models import TypedModelMeta
from archivebox.config import CONSTANTS
def normalize_config_json_values(config: Any) -> Any:
if not isinstance(config, dict):
return config
normalized = dict(config)
for key, value in list(normalized.items()):
if not isinstance(value, str) or len(value) < 2:
continue
if value[:1] != '"' or value[-1:] != '"':
continue
try:
decoded = json.loads(value)
except ValueError:
continue
if isinstance(decoded, str):
normalized[key] = decoded
return normalized
def get_or_create_system_user_pk(username="system"):
User = get_user_model()
# If there's exactly one superuser, use that for all system operations
if User.objects.filter(is_superuser=True).count() == 1:
return User.objects.filter(is_superuser=True).values_list("pk", flat=True)[0]
# Otherwise get or create the system user
user, _ = User.objects.get_or_create(
username=username,
defaults={"is_staff": True, "is_superuser": True, "email": "", "password": "!"},
)
return user.pk
class AutoDateTimeField(models.DateTimeField):
"""DateTimeField that automatically updates on save (legacy compatibility)."""
def pre_save(self, model_instance, add):
if add or self.attname not in model_instance.__dict__ or not model_instance.__dict__[self.attname]:
value = timezone.now()
setattr(model_instance, self.attname, value)
return value
return super().pre_save(model_instance, add)
class ModelWithUUID(models.Model):
id = CompactUUIDField(primary_key=True, default=uuid7, editable=False, unique=True)
created_at = models.DateTimeField(default=timezone.now, db_index=True)
modified_at = models.DateTimeField(auto_now=True)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
default=get_or_create_system_user_pk,
null=False,
db_index=True,
)
class Meta(TypedModelMeta):
abstract = True
def __str__(self) -> str:
return f"[{self.id}] {self.__class__.__name__}"
@property
def admin_change_url(self) -> str:
return f"/admin/{self._meta.app_label}/{self._meta.model_name}/{self.pk}/change/"
@property
def api_url(self) -> str:
return str(reverse_lazy("api-1:get_any", args=[self.id]))
@property
def api_docs_url(self) -> str:
return f"/api/v1/docs#/{self._meta.app_label.title()}%20Models/api_v1_{self._meta.app_label}_get_{self._meta.db_table}"
class ModelWithNotes(models.Model):
"""Mixin for models with a notes field."""
notes = models.TextField(blank=True, null=False, default="")
class Meta(TypedModelMeta):
abstract = True
class ModelWithHealthStats(models.Model):
"""Mixin for models with health tracking fields."""
num_uses_failed = models.PositiveIntegerField(default=0)
num_uses_succeeded = models.PositiveIntegerField(default=0)
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)
return round((self.num_uses_succeeded / total) * 100)
def increment_health_stats(self, success: bool):
"""Atomically increment success or failure counter using F() expression."""
field = "num_uses_succeeded" if success else "num_uses_failed"
type(self).objects.filter(pk=self.pk).update(
**{
field: F(field) + 1,
"modified_at": timezone.now(),
},
)
class ModelWithConfig(models.Model):
"""Mixin for models with a JSON config field."""
config = models.JSONField(default=dict, null=True, blank=True, editable=True)
class Meta(TypedModelMeta):
abstract = True
def save(self, *args, **kwargs):
normalized_config = normalize_config_json_values(self.config)
if normalized_config != self.config:
self.config = normalized_config
update_fields = kwargs.get("update_fields")
if update_fields is not None:
kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "config"]))
super().save(*args, **kwargs)
class ModelWithDeleteAfter(models.Model):
delete_after_final_statuses: tuple[str, ...] = ()
delete_at = models.DateTimeField(default=None, null=True, blank=True, db_index=True)
class Meta(TypedModelMeta):
abstract = True
def save(self, *args, **kwargs):
update_fields = kwargs.get("update_fields")
if self.delete_at is None:
self.set_delete_at_from_config()
if self.delete_at is not None and update_fields is not None:
kwargs["update_fields"] = tuple(dict.fromkeys([*update_fields, "delete_at"]))
super().save(*args, **kwargs)
def get_delete_after_config_value(self):
from archivebox.config.common import get_config
return get_config(include_machine=False, resolve_plugins=False).DELETE_AFTER
def set_delete_at_from_config(self, config_value=None) -> bool:
if self.delete_at is not None:
return False
from archivebox.config.common import parse_delete_after
duration = parse_delete_after(self.get_delete_after_config_value() if config_value is None else config_value)
if duration is None:
return False
self.delete_at = (self.created_at or timezone.now()) + duration
return True
@classmethod
def missing_delete_at_candidates(cls):
return cls.objects.none()
@classmethod
def delete_expired(cls, *, batch_size: int = 100, backfill_missing: bool = True) -> int:
if backfill_missing:
missing_delete_at = list(cls.missing_delete_at_candidates().order_by("created_at", "pk")[:batch_size])
for obj in missing_delete_at:
if obj.set_delete_at_from_config():
cls.objects.filter(pk=obj.pk, delete_at__isnull=True).update(
delete_at=obj.delete_at,
modified_at=timezone.now(),
)
# Keep the expiration sweep anchored on delete_at. Some large tables
# have millions of final-status rows but almost no retained rows; adding
# the status filter before SQLite has narrowed by delete_at can make the
# runner scan the hot status index before it claims new work.
due_pks = list(
cls.objects.filter(delete_at__isnull=False, delete_at__lte=timezone.now())
.order_by("delete_at", "pk")
.values_list("pk", flat=True)[:batch_size],
)
if not due_pks:
return 0
queryset = cls.objects.filter(pk__in=due_pks)
if cls.delete_after_final_statuses:
queryset = queryset.filter(status__in=cls.delete_after_final_statuses)
count = 0
expired = list(queryset.order_by("delete_at", "pk"))
for obj in expired:
obj.delete()
count += 1
return count
class ModelWithOutputDir(ModelWithUUID):
class Meta(ModelWithUUID.Meta):
abstract = True
_delete_signal_registered = False
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
output_dir = Path(self.output_dir)
# Avoid holding SQLite write transactions open across slow filesystem work.
transaction.on_commit(lambda: output_dir.mkdir(parents=True, exist_ok=True))
# Note: index.json is deprecated, models should use write_index_jsonl() for full data
@property
def output_dir_parent(self) -> str:
return f"{self._meta.model_name}s"
@property
def output_dir_name(self) -> str:
return str(self.id)
@property
def output_dir_str(self) -> str:
return f"{self.output_dir_parent}/{self.output_dir_name}"
@property
def output_dir(self) -> Path:
raise NotImplementedError(f"{self.__class__.__name__} must implement output_dir property")
def output_paths_for_delete(self) -> tuple[Path, ...]:
return (Path(self.output_dir),)
@classmethod
def validate_output_paths_for_delete(cls, paths) -> tuple[Path, ...]:
data_dir = CONSTANTS.DATA_DIR.resolve()
safe_paths = []
for raw_path in paths:
path = Path(raw_path)
is_safe = False
for candidate in (path.absolute(), path.resolve()):
try:
candidate.relative_to(data_dir)
is_safe = True
break
except ValueError:
continue
if not is_safe:
raise ValueError(f"Refusing to delete output path outside DATA_DIR: {path}")
safe_paths.append(path)
return tuple(safe_paths)
@classmethod
def delete_output_paths(cls, paths) -> None:
for path in cls.validate_output_paths_for_delete(paths):
if path.is_symlink() or path.is_file():
path.unlink(missing_ok=True)
elif path.is_dir():
shutil.rmtree(path, ignore_errors=True)
def schedule_delete_cleanup(self, *, using: str | None = None) -> None:
"""Capture output paths before DB deletion and remove them after commit."""
paths = self.validate_output_paths_for_delete(self.output_paths_for_delete())
transaction.on_commit(lambda: self.delete_output_paths(paths), using=using)
@classmethod
def register_delete_signal(cls) -> None:
if cls._delete_signal_registered:
return
def schedule_output_dir_cleanup(sender, instance, using, **kwargs):
if not isinstance(instance, ModelWithOutputDir):
return
instance.schedule_delete_cleanup(using=using)
pre_delete.connect(
schedule_output_dir_cleanup,
dispatch_uid="archivebox.output_dir_cleanup_on_delete",
weak=False,
)
cls._delete_signal_registered = True
+241 -201
View File
@@ -1,224 +1,264 @@
__package__ = "archivebox.cli"
__command__ = "archivebox"
__package__ = 'archivebox.cli'
__command__ = 'archivebox'
import os
import sys
import argparse
import threading
from time import sleep
from collections.abc import Mapping
from rich import print
from typing import Optional, List, IO, Union, Iterable
from pathlib import Path
from importlib import import_module
import rich_click as click
from rich.console import Console
BUILTIN_LIST = list
from archivebox.config.version import VERSION
CLI_DIR = Path(__file__).resolve().parent
STDERR = Console(stderr=True)
# rewrite setup -> install for backwards compatibility
if len(sys.argv) > 1 and sys.argv[1] == 'setup':
from rich import print
print(':warning: [bold red]DEPRECATED[/bold red] `archivebox setup` is deprecated, use `archivebox install` instead')
sys.argv[1] = 'install'
if '--debug' in sys.argv:
os.environ['DEBUG'] = 'True'
sys.argv.remove('--debug')
if "--debug" in sys.argv:
os.environ["DEBUG"] = "True"
sys.argv.remove("--debug")
# def list_subcommands() -> Dict[str, str]:
# """find and import all valid archivebox_<subcommand>.py files in CLI_DIR"""
# COMMANDS = []
# for filename in os.listdir(CLI_DIR):
# if is_cli_module(filename):
# subcommand = filename.replace('archivebox_', '').replace('.py', '')
# module = import_module('.archivebox_{}'.format(subcommand), __package__)
# assert is_valid_cli_module(module, subcommand)
# COMMANDS.append((subcommand, module.main.__doc__))
# globals()[subcommand] = module.main
# display_order = lambda cmd: (
# display_first.index(cmd[0])
# if cmd[0] in display_first else
# 100 + len(cmd[0])
# )
# return dict(sorted(COMMANDS, key=display_order))
# Universal `--init` flag: when passed to ANY subcommand (e.g. `archivebox server --init`,
# `archivebox add --init`, `archivebox shell --init`), run a `quick` archivebox init before
# the subcommand executes. `--quick-init` is the old server spelling. Strip either from
# argv here so each subcommand's own click parser never sees it. Ignored for `help` and
# `init` themselves.
if "--init" in sys.argv or "--quick-init" in sys.argv:
sys.argv = [arg for arg in sys.argv if arg not in ("--init", "--quick-init")]
os.environ["ARCHIVEBOX_WANTS_INIT"] = "1"
# just define it statically, it's much faster:
SUBCOMMAND_MODULES = {
'help': 'archivebox_help',
'version': 'archivebox_version' ,
'init': 'archivebox_init',
'install': 'archivebox_install',
##############################################
'config': 'archivebox_config',
'add': 'archivebox_add',
'remove': 'archivebox_remove',
'update': 'archivebox_update',
'list': 'archivebox_list',
'status': 'archivebox_status',
'schedule': 'archivebox_schedule',
'server': 'archivebox_server',
'shell': 'archivebox_shell',
'manage': 'archivebox_manage',
# 'oneshot': 'archivebox_oneshot',
}
# every imported command module must have these properties in order to be valid
required_attrs = ('__package__', '__command__', 'main')
# basic checks to make sure imported files are valid subcommands
is_cli_module = lambda fname: fname.startswith('archivebox_') and fname.endswith('.py')
is_valid_cli_module = lambda module, subcommand: (
all(hasattr(module, attr) for attr in required_attrs)
and module.__command__.split(' ')[-1] == subcommand
)
class LazySubcommands(Mapping):
def keys(self):
return SUBCOMMAND_MODULES.keys()
def values(self):
return [self[key] for key in self.keys()]
def items(self):
return [(key, self[key]) for key in self.keys()]
def __getitem__(self, key):
module = import_module(f'.{SUBCOMMAND_MODULES[key]}', __package__)
assert is_valid_cli_module(module, key)
return module.main
def __iter__(self):
return iter(SUBCOMMAND_MODULES.keys())
def __len__(self):
return len(SUBCOMMAND_MODULES)
CLI_SUBCOMMANDS = LazySubcommands()
class ArchiveBoxGroup(click.Group):
"""lazy loading click group for archivebox commands"""
# these common commands will appear sorted before any others for ease-of-use
meta_cmds = ('help', 'version') # dont require valid data folder at all
setup_cmds = ('init', 'setup', 'install') # require valid data folder, but dont require DB present in it yet
archive_cmds = ('add', 'remove', 'update', 'list', 'status', 'schedule', 'server', 'shell', 'manage') # require valid data folder + existing db present
fake_db = ("oneshot",) # use fake in-memory db
meta_commands = {
"help": "archivebox.cli.archivebox_help.main",
"version": "archivebox.cli.archivebox_version.main",
"mcp": "archivebox.cli.archivebox_mcp.main",
"oneshot": "archivebox.cli.archivebox_oneshot.main",
}
setup_commands = {
"init": "archivebox.cli.archivebox_init.main",
"install": "archivebox.cli.archivebox_install.main",
}
# Model commands (CRUD operations via subcommands)
model_commands = {
"crawl": "archivebox.cli.archivebox_crawl.main",
"snapshot": "archivebox.cli.archivebox_snapshot.main",
"archiveresult": "archivebox.cli.archivebox_archiveresult.main",
"tag": "archivebox.cli.archivebox_tag.main",
"binary": "archivebox.cli.archivebox_binary.main",
"process": "archivebox.cli.archivebox_process.main",
"machine": "archivebox.cli.archivebox_machine.main",
"persona": "archivebox.cli.archivebox_persona.main",
}
archive_commands = {
# High-level commands
"add": "archivebox.cli.archivebox_add.main",
"extract": "archivebox.cli.archivebox_extract.main",
"list": "archivebox.cli.archivebox_list.main",
"remove": "archivebox.cli.archivebox_remove.main",
"run": "archivebox.cli.archivebox_run.main",
"update": "archivebox.cli.archivebox_update.main",
"status": "archivebox.cli.archivebox_status.main",
"search": "archivebox.cli.archivebox_search.main",
"config": "archivebox.cli.archivebox_config.main",
"schedule": "archivebox.cli.archivebox_schedule.main",
"server": "archivebox.cli.archivebox_server.main",
"shell": "archivebox.cli.archivebox_shell.main",
"manage": "archivebox.cli.archivebox_manage.main",
# Introspection commands
"pluginmap": "archivebox.cli.archivebox_pluginmap.main",
}
all_subcommands = {
**meta_commands,
**setup_commands,
**model_commands,
**archive_commands,
}
renamed_commands = {
"setup": "install",
"import": "add",
"archive": "add",
}
@classmethod
def get_canonical_name(cls, cmd_name):
return cls.renamed_commands.get(cmd_name, cmd_name)
@classmethod
def _needs_django_for_lazy_import(cls, cmd_name: str) -> bool:
wants_help = any(arg in ("-h", "--help", "--version") for arg in sys.argv[1:])
return not wants_help and (cmd_name in cls.archive_commands or cmd_name in cls.model_commands)
@classmethod
def _setup_django_for_lazy_import(cls, cmd_name: str) -> None:
if not cls._needs_django_for_lazy_import(cmd_name):
return
from django.apps import apps
if apps.ready:
return
from archivebox.config.django import setup_django
setup_django()
def get_command(self, ctx, cmd_name):
# handle renamed commands
if cmd_name in self.renamed_commands:
new_name = self.renamed_commands[cmd_name]
STDERR.print(
f" [violet]Hint:[/violet] `archivebox {cmd_name}` has been renamed to `archivebox {new_name}`",
)
cmd_name = new_name
ctx.invoked_subcommand = cmd_name
# handle lazy loading of commands
if cmd_name in self.all_subcommands:
self._setup_django_for_lazy_import(cmd_name)
return self._lazy_load(cmd_name)
# fall-back to using click's default command lookup
return super().get_command(ctx, cmd_name)
@classmethod
def _lazy_load(cls, cmd_name_or_path):
import_path = cls.all_subcommands.get(cmd_name_or_path)
if import_path is None:
import_path = cmd_name_or_path
modname, funcname = import_path.rsplit(".", 1)
mod = import_module(modname)
func = vars(mod)[funcname]
if func.__doc__ is None:
raise ValueError(f"lazy loading of {import_path} failed - no docstring found on method")
return func
display_first = (*meta_cmds, *setup_cmds, *archive_cmds)
@click.group(cls=ArchiveBoxGroup, invoke_without_command=True)
@click.option("--help", "-h", is_flag=True, help="Show help")
@click.version_option(VERSION, "-v", "--version", package_name="archivebox", message="%(version)s")
@click.pass_context
def cli(ctx, help=False):
"""ArchiveBox: The self-hosted internet archive"""
subcommand = ArchiveBoxGroup.get_canonical_name(ctx.invoked_subcommand)
# if --help is passed or no subcommand is given, show custom help message
if help or ctx.invoked_subcommand is None:
ctx.invoke(ctx.command.get_command(ctx, "help"))
# if the subcommand is in archive_commands or model_commands,
# then we need to set up the django environment and check that we're in a valid data folder
wants_help = any(arg in ("-h", "--help", "--version") for arg in sys.argv[1:])
if not wants_help and (subcommand in ArchiveBoxGroup.archive_commands or subcommand in ArchiveBoxGroup.model_commands):
try:
if subcommand == "server":
run_in_debug = "--reload" in sys.argv or os.environ.get("DEBUG") in ("1", "true", "True", "TRUE", "yes")
if run_in_debug:
os.environ["ARCHIVEBOX_RUNSERVER"] = "1"
if "--reload" in sys.argv:
os.environ["ARCHIVEBOX_AUTORELOAD"] = "1"
from archivebox.config.django import setup_django
from archivebox.misc.checks import check_data_folder, check_migrations
setup_django()
if os.environ.get("ARCHIVEBOX_WANTS_INIT") == "1" and subcommand not in ("init", "help"):
# Universal `--init` was passed: build/upgrade the data folder before
# the regular preflight runs, so it succeeds on a fresh dir and an
# out-of-date schema both. Drop the env var afterwards so spawned
# subprocesses (supervisord workers, daphne, runner, etc.) inherit
# a clean env and don't re-trigger init in every child.
from archivebox.cli.archivebox_init import init as archivebox_init
archivebox_init(quick=True)
os.environ.pop("ARCHIVEBOX_WANTS_INIT", None)
check_data_folder()
if subcommand != "update":
check_migrations(auto_apply=True)
except Exception as e:
STDERR.print(f"[red][X] Error setting up Django or checking data folder: {e}[/red]")
if subcommand not in ("manage", "shell"): # not all management commands need django to be setup beforehand
raise
IGNORED_BG_THREADS = ('MainThread', 'ThreadPoolExecutor', 'IPythonHistorySavingThread', 'Scheduler') # threads we dont have to wait for before exiting
def main(args=None, prog_name=None):
from archivebox.config.constants import CONSTANTS
def wait_for_bg_threads_to_exit(thread_names: Iterable[str]=(), ignore_names: Iterable[str]=IGNORED_BG_THREADS, timeout: int=60) -> int:
"""
Block until the specified threads exit. e.g. pass thread_names=('default_hook_handler',) to wait for webhooks.
Useful for waiting for signal handlers, webhooks, etc. to finish running after a mgmt command completes.
"""
os.environ.setdefault("ABX_PLUGINS_DIR", str(CONSTANTS.USER_PLUGINS_DIR))
wait_for_all: bool = thread_names == ()
# show `docker run archivebox xyz` in help messages if running in docker
IN_DOCKER = os.environ.get("IN_DOCKER", False) in ("1", "true", "True", "TRUE", "yes")
IS_TTY = sys.stdin.isatty()
prog_name = prog_name or (f"docker compose run{'' if IS_TTY else ' -T'} archivebox" if IN_DOCKER else "archivebox")
thread_matches = lambda thread, ptns: any(ptn in repr(thread) for ptn in ptns)
previous_unraisablehook = sys.unraisablehook
should_wait = lambda thread: (
not thread_matches(thread, ignore_names)
and (wait_for_all or thread_matches(thread, thread_names)))
def ignore_shutdown_unraisable(unraisable):
if isinstance(unraisable.exc_value, (KeyboardInterrupt, SystemExit)):
return
previous_unraisablehook(unraisable)
for tries in range(timeout):
all_threads = [*threading.enumerate()]
blocking_threads = [*filter(should_wait, all_threads)]
threads_summary = ', '.join(repr(t) for t in blocking_threads)
if blocking_threads:
sleep(1)
if tries == 5: # only show stderr message if we need to wait more than 5s
print(
f'[…] Waiting up to {timeout}s for background jobs (e.g. webhooks) to finish...',
threads_summary,
file=sys.stderr,
)
else:
return tries
raise Exception(f'Background threads failed to exit after {tries}s: {threads_summary}')
def run_subcommand(subcommand: str,
subcommand_args: List[str] | None = None,
stdin: Optional[IO]=None,
pwd: Union[Path, str, None]=None) -> None:
"""Run a given ArchiveBox subcommand with the given list of args"""
subcommand_args = subcommand_args or []
from archivebox.misc.checks import check_migrations
from archivebox.config.django import setup_django
# print('DATA_DIR is', DATA_DIR)
# print('pwd is', os.getcwd())
cmd_requires_db = (subcommand in archive_cmds)
init_pending = '--init' in subcommand_args or '--quick-init' in subcommand_args
check_db = cmd_requires_db and not init_pending
setup_django(in_memory_db=subcommand in fake_db, check_db=check_db)
for ignore_pattern in ('help', '-h', '--help', 'version', '--version'):
if ignore_pattern in sys.argv[:4]:
cmd_requires_db = False
break
if subcommand in archive_cmds:
if cmd_requires_db:
check_migrations()
module = import_module('.archivebox_{}'.format(subcommand), __package__)
module.main(args=subcommand_args, stdin=stdin, pwd=pwd) # type: ignore
# wait for webhooks, signals, and other background jobs to finish before exit
wait_for_bg_threads_to_exit(timeout=60)
class NotProvided:
def __len__(self):
return 0
def __bool__(self):
return False
def __repr__(self):
return '<not provided>'
Omitted = Union[None, NotProvided]
OMITTED = NotProvided()
def main(args: List[str] | Omitted=OMITTED, stdin: IO | Omitted=OMITTED, pwd: str | None=None) -> None:
# print('STARTING CLI MAIN ENTRYPOINT')
args = sys.argv[1:] if args is OMITTED else args
stdin = sys.stdin if stdin is OMITTED else stdin
parser = argparse.ArgumentParser(
prog=__command__,
description='ArchiveBox: The self-hosted internet archive',
add_help=False,
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
'--help', '-h',
action='store_true',
help=CLI_SUBCOMMANDS['help'].__doc__,
)
group.add_argument(
'--version',
action='store_true',
help=CLI_SUBCOMMANDS['version'].__doc__,
)
group.add_argument(
"subcommand",
type=str,
help= "The name of the subcommand to run",
nargs='?',
choices=CLI_SUBCOMMANDS.keys(),
default=None,
)
parser.add_argument(
"subcommand_args",
help="Arguments for the subcommand",
nargs=argparse.REMAINDER,
)
command = parser.parse_args(args or ())
if command.version:
command.subcommand = 'version'
elif command.help or command.subcommand is None:
command.subcommand = 'help'
if command.subcommand not in ('version',):
from ..logging_util import log_cli_command
log_cli_command(
subcommand=command.subcommand,
subcommand_args=command.subcommand_args,
stdin=stdin or None,
)
sys.unraisablehook = ignore_shutdown_unraisable
try:
cli(args=args, prog_name=prog_name, standalone_mode=False)
except click.Abort:
STDERR.print("\n[red][X] Got CTRL+C. Exiting...[/red]")
raise SystemExit(130) from None
except click.ClickException as err:
err.show()
raise SystemExit(err.exit_code) from None
except click.exceptions.Exit as err:
raise SystemExit(err.exit_code) from None
run_subcommand(
subcommand=command.subcommand,
subcommand_args=command.subcommand_args,
stdin=stdin or None,
)
except KeyboardInterrupt:
STDERR.print("\n[red][X] Got CTRL+C. Exiting...[/red]")
raise SystemExit(130) from None
finally:
sys.unraisablehook = previous_unraisablehook
if __name__ == "__main__":
main()
print('\n\n[red][X] Got CTRL+C. Exiting...[/red]')
+128 -405
View File
@@ -1,419 +1,142 @@
#!/usr/bin/env python3
from __future__ import annotations
__package__ = "archivebox.cli"
__command__ = "archivebox add"
__package__ = 'archivebox.cli'
__command__ = 'archivebox add'
import sys
import os
from pathlib import Path
import argparse
from typing import Any, TYPE_CHECKING
from typing import List, Optional, IO
import rich_click as click
from archivebox.misc.util import docstring
from archivebox.config import DATA_DIR
from archivebox.config.common import ARCHIVING_CONFIG
from archivebox.config import CONSTANTS
from archivebox.misc.util import enforce_types, docstring
from archivebox.misc.util import parse_filesize_to_bytes
from ..main import add
from ..parsers import PARSERS
from ..logging_util import SmartFormatter, accept_stdin, stderr
if TYPE_CHECKING:
from django.db.models import QuerySet
from archivebox.crawls.models import Crawl
from archivebox.core.models import Snapshot
def _collect_input_urls(args: tuple[str, ...], *, parser: str = "auto") -> list[str]:
from archivebox.misc.jsonl import read_args_or_stdin
from archivebox.misc.util import validate_url
urls: list[str] = []
for record in read_args_or_stdin(args):
url = record.get("url")
if isinstance(url, str) and url:
try:
urls.append(validate_url(url))
except ValueError as err:
raise click.BadParameter(str(err), param_hint="URL") from err
urls_field = record.get("urls")
if isinstance(urls_field, str):
for line in urls_field.splitlines():
line = line.strip()
if line and not line.startswith("#"):
try:
urls.append(validate_url(line))
except ValueError as err:
raise click.BadParameter(str(err), param_hint="URL") from err
return urls
@enforce_types
def add(
urls: str | list[str],
snapshot_ids: list[str] | None = None,
depth: int | str = 0,
max_urls: int = 0,
crawl_max_size: int | str = 0,
crawl_timeout: int = 0,
snapshot_max_size: int | str = 0,
crawl_max_concurrent_snapshots: int | None = None,
tag: str = "",
url_allowlist: str = "",
url_denylist: str = "",
parser: str = "auto",
plugins: str = "",
persona: str = "Default",
index_only: bool = False,
bg: bool = False,
created_by_id: int | None = None,
config: dict[str, Any] | None = None,
) -> tuple[Crawl, QuerySet[Snapshot]]:
"""Add a URL list or imported URL document to a new Crawl."""
from rich import print
depth = int(depth)
max_urls = int(max_urls or 0)
crawl_max_size = parse_filesize_to_bytes(crawl_max_size)
crawl_timeout = int(crawl_timeout or 0)
snapshot_max_size = parse_filesize_to_bytes(snapshot_max_size)
from archivebox.config.permissions import USER, HOSTNAME
from archivebox.config.common import get_config
config_overrides = dict(config or {})
runtime_config = get_config()
crawl_max_concurrent_snapshots_override = crawl_max_concurrent_snapshots is not None
if crawl_max_concurrent_snapshots is None:
crawl_max_concurrent_snapshots = runtime_config.CRAWL_MAX_CONCURRENT_SNAPSHOTS
crawl_max_concurrent_snapshots = int(crawl_max_concurrent_snapshots)
if depth not in (0, 1, 2, 3, 4):
raise ValueError("Depth must be 0-4")
if max_urls < 0:
raise ValueError("max_urls must be >= 0")
if crawl_max_size < 0:
raise ValueError("crawl_max_size must be >= 0")
if crawl_timeout < 0:
raise ValueError("crawl_timeout must be >= 0")
if snapshot_max_size < 0:
raise ValueError("snapshot_max_size must be >= 0")
if crawl_max_concurrent_snapshots < 1:
raise ValueError("crawl_max_concurrent_snapshots must be >= 1")
# import models once django is set up
from archivebox.crawls.models import Crawl
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.personas.models import Persona
from archivebox.misc.logging_util import printable_filesize
from archivebox.misc.system import get_dir_size
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
from django.utils import timezone
created_by_id = created_by_id or get_or_create_system_user_pk()
started_at = timezone.now()
if isinstance(urls, str):
source_text = urls
else:
from archivebox.misc.util import validate_url
source_text = "\n".join(validate_url(str(url)) for url in urls)
# 2. Create a new Crawl with inline URLs
# Foreground add must claim runner ownership before publishing runnable
# work. Otherwise an existing server runner can lease the new Crawl in the
# gap between Crawl.objects.create() and current_command(), then be killed
# by the newer add process halfway through its first Snapshot hook.
command = None
if not bg and not index_only:
from archivebox.machine.models import Process
from archivebox.core.takeover_util import current_command
command = current_command(Process.TypeChoices.ADD, data_dir=CONSTANTS.DATA_DIR)
cli_args = [*sys.argv]
if cli_args[0].lower().endswith("archivebox"):
cli_args[0] = "archivebox"
cmd_str = " ".join(cli_args)
timestamp = timezone.now().strftime("%Y-%m-%d__%H-%M-%S")
persona_name = (persona or "Default").strip() or "Default"
plugins = plugins or ""
persona_obj = Persona.get_or_create_named(persona_name)
persona_obj.ensure_dirs()
effective_persona_config = get_config(persona=persona_obj)
crawl_config = {
"PERMISSIONS": str(effective_persona_config.PERMISSIONS),
**({"INDEX_ONLY": True} if index_only else {}),
**({"PLUGINS": plugins} if plugins else {}),
**(
{"CRAWL_MAX_CONCURRENT_SNAPSHOTS": crawl_max_concurrent_snapshots}
if crawl_max_concurrent_snapshots_override
and crawl_max_concurrent_snapshots != int(effective_persona_config.CRAWL_MAX_CONCURRENT_SNAPSHOTS)
else {}
),
**({"CRAWL_MAX_URLS": max_urls} if max_urls else {}),
**({"CRAWL_MAX_SIZE": crawl_max_size} if crawl_max_size else {}),
**({"CRAWL_TIMEOUT": crawl_timeout} if crawl_timeout else {}),
**({"SNAPSHOT_MAX_SIZE": snapshot_max_size} if snapshot_max_size else {}),
**({"PARSER": parser} if parser != "auto" else {}),
**({"URL_ALLOWLIST": url_allowlist} if url_allowlist else {}),
**({"URL_DENYLIST": url_denylist} if url_denylist else {}),
}
# Caller-supplied overrides (e.g. {"ONLY_NEW": False}) are the highest
# priority for crawl-frozen keys. Runtime-derived execution keys are
# stripped by Crawl.save() and rederived when hooks run.
crawl_config.update(config_overrides)
# Crawl.urls is the user's submitted source, not a derived work queue for
# this add path. Keeping it byte-for-byte readable is what lets API/UI/CLI
# callers audit or resume imports without losing RSS/Netscape/JSON metadata
# that is not representable as one plain URL per line.
try:
crawl = Crawl.objects.create(
urls=source_text,
max_depth=depth,
tags_str=tag,
persona_id=persona_obj.id,
label=f"{USER}@{HOSTNAME} $ {cmd_str} [{timestamp}]",
created_by_id=created_by_id,
status=Crawl.StatusChoices.QUEUED,
retry_at=None if index_only else timezone.now(),
config=crawl_config,
)
first_url = crawl.get_urls_list()[0] if crawl.get_urls_list() else ""
except BaseException:
if command is not None:
command.mark_exited(exit_code=1)
raise
if command is not None:
command.url = first_url
command.save(update_fields=["url", "modified_at"])
print(f"[green]\\[+] Created Crawl {crawl.id} with max_depth={depth}[/green]")
print(f" [dim]First URL: {first_url}[/dim]")
# The runner parses Crawl.urls after claiming the Crawl, then persists the
# resulting depth-0 Snapshot facts. Recursive discoveries start at depth 1.
if index_only:
print("[yellow]\\[*] Index-only mode - URLs queued, runner not started[/yellow]")
return crawl, crawl.snapshot_set.none()
# 5. Start the crawl runner to process the queue
# The runner will:
# - Process Crawl -> create Snapshots from all URLs
# - Process Snapshots -> run extractors
# - Parser extractors discover new URLs -> create child Snapshots
# - Repeat until max_depth reached
if bg:
# Background mode: just queue work and return (background runner via server will pick it up).
print(
"[yellow]\\[*] URLs queued. The background runner will process them (run `archivebox server` or `archivebox run --daemon` if not already running).[/yellow]",
)
from archivebox.services.runner import ensure_background_runner
ensure_background_runner()
else:
# Foreground mode: run full crawl runner until all work is done
print("[green]\\[*] Starting crawl runner to process crawl...[/green]")
from archivebox.core.takeover_util import command_owns_foreground_runner, standby_until_foreground_runner_needed
from archivebox.workers.supervisord_util import get_existing_supervisord_process, run_runner_worker, stop_own_supervisord_process
assert command is not None
exit_code = 0
def crawl_is_complete() -> bool:
crawl.refresh_from_db(fields=["status"])
return crawl.status not in crawl.RUNNABLE_STATES
try:
try:
with foreground_shutdown_signals(first_signal_message=None), foreground_parent_watchdog():
while True:
standby = standby_until_foreground_runner_needed(
command,
data_dir=CONSTANTS.DATA_DIR,
work_is_complete=crawl_is_complete,
)
if standby["work_completed"]:
break
exit_code = run_runner_worker(
["--crawl-id", str(crawl.id)],
name=f"worker_runner_add_{os.getpid()}",
interactive_interrupts=True,
config=get_config(crawl=crawl),
)
crawl.refresh_from_db(fields=["status", "retry_at"])
if exit_code == 0 and crawl.status == crawl.StatusChoices.SEALED:
break
# A shared supervisord can be stopped by another
# foreground owner (for example `archivebox server`
# shutting down) and stop the one-shot add runner while
# this add's crawl is still runnable. Keep the add
# process alive so it can re-enter the normal
# foreground-runner ownership loop and finish its crawl.
supervisor_gone = exit_code == 1 and get_existing_supervisord_process(quiet=True) is None
if crawl.status in crawl.RUNNABLE_STATES and (exit_code in (0, 130, 143) or supervisor_gone):
continue
if exit_code == 0:
break
if not command_owns_foreground_runner(command, data_dir=CONSTANTS.DATA_DIR):
continue
raise SystemExit(exit_code)
except KeyboardInterrupt:
exit_code = 130
print("\n[red][X] archivebox add interrupted.[/red]")
print("[yellow]Hint: resume this crawl with:[/yellow]")
print(f" [green]archivebox run --crawl-id={crawl.id}[/green]")
raise SystemExit(exit_code)
finally:
command.mark_exited(exit_code=exit_code)
stop_own_supervisord_process()
# Print summary for foreground runs
try:
crawl.refresh_from_db()
try:
from django.db.models import Count, Sum
totals = crawl.snapshot_set.aggregate(snapshot_count=Count("id"), total_bytes=Sum("output_size"))
snapshots_count = int(totals["snapshot_count"] or 0)
total_bytes = int(totals["total_bytes"] or 0)
except Exception:
snapshots_count = crawl.snapshot_set.count()
total_bytes, _, _ = get_dir_size(crawl.output_dir)
total_size = printable_filesize(total_bytes)
total_time = timezone.now() - started_at
total_seconds = int(total_time.total_seconds())
mins, secs = divmod(total_seconds, 60)
hours, mins = divmod(mins, 60)
if hours:
duration_str = f"{hours}h {mins}m {secs}s"
elif mins:
duration_str = f"{mins}m {secs}s"
else:
duration_str = f"{secs}s"
# Output dir relative to DATA_DIR
try:
rel_output = Path(crawl.output_dir).relative_to(CONSTANTS.DATA_DIR)
rel_output_str = f"./{rel_output}"
except Exception:
rel_output_str = str(crawl.output_dir)
from archivebox.core.routes_util import build_admin_url
admin_url = build_admin_url(f"/admin/crawls/crawl/{crawl.id}/change/", config=config)
print("\n[bold]crawl output saved to:[/bold]")
print(f" {rel_output_str}")
print(f" {admin_url}")
print(f"\n[bold]total urls snapshotted:[/bold] {snapshots_count}")
print(f"[bold]total size:[/bold] {total_size}")
print(f"[bold]total time:[/bold] {duration_str}")
except Exception:
# Summary is best-effort; avoid failing the command if something goes wrong
pass
# 6. Return the list of Snapshots in this crawl
snapshots = crawl.snapshot_set.all()
return crawl, snapshots
@click.command()
@click.option(
"--depth",
"-d",
type=click.Choice([str(i) for i in range(5)]),
default="0",
help="Recursively archive linked pages up to N hops away",
)
@click.option("--max-urls", type=int, default=0, help="Maximum number of URLs to snapshot for this crawl (0 = unlimited)")
@click.option("--crawl-max-size", default="0", help="Maximum total crawl size in bytes or units like 45mb / 1gb (0 = unlimited)")
@click.option("--crawl-timeout", type=int, default=0, help="Maximum total crawl runtime in seconds (0 = unlimited)")
@click.option("--snapshot-max-size", default="0", help="Maximum per-snapshot size in bytes or units like 45mb / 1gb (0 = unlimited)")
@click.option("--crawl-max-concurrent-snapshots", type=int, default=None, help="Maximum snapshots to archive concurrently within one crawl")
@click.option("--tag", "-t", default="", help="Comma-separated list of tags to add to each snapshot e.g. tag1,tag2,tag3")
@click.option("--url-allowlist", "--domain-allowlist", default="", help="Comma-separated URL/domain allowlist for this crawl")
@click.option("--url-denylist", "--domain-denylist", default="", help="Comma-separated URL/domain denylist for this crawl")
@click.option("--parser", default="auto", help="Parser for reading input URLs (auto, txt, html, rss, json, jsonl, netscape, ...)")
@click.option("--plugins", "-p", default="", help="Comma-separated list of plugins to run e.g. title,favicon,screenshot,singlefile,...")
@click.option("--extract", default="", help="Comma-separated plugins or output types to extract e.g. title,pdf,text/html,image,...")
@click.option("--persona", default="Default", help="Authentication profile to use when archiving")
@click.option(
"--only-new/--no-only-new",
"only_new",
default=None,
help="Skip URLs that already have a snapshot (default: inherit from ONLY_NEW config). "
"Pass --no-only-new to force re-archive of URLs that already exist.",
)
@click.option("--index-only", is_flag=True, help="Just add the URLs to the index without archiving them now")
@click.option("--overwrite", is_flag=True, help="Re-archive URLs even if they already exist (alias for --no-only-new)")
@click.option("--update", is_flag=True, help="Re-archive URLs even if they already exist (alias for --no-only-new)")
@click.option("--bg", is_flag=True, help="Run archiving in background (queue work and return immediately)")
@click.argument("urls", nargs=-1, type=click.Path())
@docstring(add.__doc__)
def main(**kwargs):
"""Add a new URL or list of URLs to your archive"""
def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional[str]=None) -> None:
parser = argparse.ArgumentParser(
prog=__command__,
description=add.__doc__,
add_help=True,
formatter_class=SmartFormatter,
)
parser.add_argument(
'--tag', '-t',
type=str,
default='',
help="Tag the added URLs with the provided tags e.g. --tag=tag1,tag2,tag3",
)
parser.add_argument(
'--update', #'-u',
action='store_true',
default=not ARCHIVING_CONFIG.ONLY_NEW, # when ONLY_NEW=True we skip updating old links
help="Also retry previously skipped/failed links when adding new links",
)
parser.add_argument(
'--update-all', #'-n',
action='store_true',
default=False,
help="Also update ALL links in index when finished adding new links",
)
parser.add_argument(
'--index-only', #'-o',
action='store_true',
help="Add the links to the main index without archiving them",
)
parser.add_argument(
'urls',
nargs='*',
type=str,
default=None,
help=(
'URLs or paths to archive e.g.:\n'
' https://getpocket.com/users/USERNAME/feed/all\n'
' https://example.com/some/rss/feed.xml\n'
' https://example.com\n'
' ~/Downloads/firefox_bookmarks_export.html\n'
' ~/Desktop/sites_list.csv\n'
)
)
parser.add_argument(
"--depth",
action="store",
default=0,
choices=[0, 1],
type=int,
help="Recursively archive all linked pages up to this many hops away"
)
parser.add_argument(
"--overwrite",
default=False,
action="store_true",
help="Re-archive URLs from scratch, overwriting any existing files"
)
parser.add_argument(
"--init", #'-i',
action='store_true',
help="Init/upgrade the curent data directory before adding",
)
parser.add_argument(
"--extract",
type=str,
help="Pass a list of the extractors to be used. If the method name is not correct, it will be ignored. \
This does not take precedence over the configuration",
default=""
)
parser.add_argument(
"--parser",
type=str,
help="Parser used to read inputted URLs.",
default="auto",
choices=["auto", *PARSERS.keys()],
)
command = parser.parse_args(args or ())
urls = command.urls
from archivebox.core.shutdown_util import foreground_parent_watchdog, foreground_shutdown_signals
stdin_urls = ''
if not urls:
stdin_urls = accept_stdin(stdin)
with foreground_shutdown_signals(), foreground_parent_watchdog():
raw_urls = kwargs.pop("urls")
if raw_urls:
urls = _collect_input_urls(raw_urls, parser=kwargs.get("parser", "auto"))
elif not sys.stdin.isatty():
urls = sys.stdin.read()
else:
urls = []
if not urls:
raise click.UsageError("No URLs provided. Pass URLs as arguments or via stdin.")
if int(kwargs.get("max_urls") or 0) < 0:
raise click.BadParameter("max_urls must be 0 or a positive integer.", param_hint="--max-urls")
if int(kwargs.get("crawl_timeout") or 0) < 0:
raise click.BadParameter("crawl_timeout must be 0 or a positive integer.", param_hint="--crawl-timeout")
try:
kwargs["crawl_max_size"] = parse_filesize_to_bytes(kwargs.get("crawl_max_size"))
except ValueError as err:
raise click.BadParameter(str(err), param_hint="--crawl-max-size") from err
try:
kwargs["snapshot_max_size"] = parse_filesize_to_bytes(kwargs.get("snapshot_max_size"))
except ValueError as err:
raise click.BadParameter(str(err), param_hint="--snapshot-max-size") from err
if kwargs.get("crawl_max_concurrent_snapshots") is not None and int(kwargs["crawl_max_concurrent_snapshots"]) < 1:
raise click.BadParameter("crawl_max_concurrent_snapshots must be at least 1.", param_hint="--crawl-max-concurrent-snapshots")
# Translate --only-new/--no-only-new into a crawl config override.
# add() takes config overrides as a dict; no per-flag kwargs.
overwrite = kwargs.pop("overwrite", False)
update = kwargs.pop("update", False)
only_new = kwargs.pop("only_new", None)
extract = kwargs.pop("extract", "")
if overwrite or update:
only_new = False
if only_new is not None:
kwargs["config"] = {"ONLY_NEW": bool(only_new)}
if extract:
from archivebox.plugins.discovery import get_plugin_catalog
catalog = get_plugin_catalog()
tokens = [token.strip() for token in extract.split(",") if token.strip()]
plugin_names = {name.lower(): name for name in catalog}
selected = [plugin_names[token.lower()] for token in tokens if token.lower() in plugin_names]
selected += catalog.matching_output(tokens)
if not selected:
raise click.UsageError(f"No plugins found matching extract types: {extract}")
existing = [token.strip() for token in (kwargs.get("plugins") or "").split(",") if token.strip()]
kwargs["plugins"] = ",".join(dict.fromkeys([*existing, *selected]))
add(urls=urls, **kwargs)
if (stdin_urls and urls) or (not stdin and not urls):
stderr(
'[X] You must pass URLs/paths to add via stdin or CLI arguments.\n',
color='red',
)
raise SystemExit(2)
add(
urls=stdin_urls or urls,
depth=command.depth,
tag=command.tag,
update=command.update,
update_all=command.update_all,
index_only=command.index_only,
overwrite=command.overwrite,
init=command.init,
extractors=command.extract,
parser=command.parser,
out_dir=pwd or DATA_DIR,
)
if __name__ == "__main__":
main()
if __name__ == '__main__':
main(args=sys.argv[1:], stdin=sys.stdin)
# TODO: Implement these
#
# parser.add_argument(
# '--mirror', #'-m',
# action='store_true',
# help='Archive an entire site (finding all linked pages below it on the same domain)',
# )
# parser.add_argument(
# '--crawler', #'-r',
# choices=('depth_first', 'breadth_first'),
# help='Controls which crawler to use in order to find outlinks in a given page',
# default=None,
# )
-383
View File
@@ -1,383 +0,0 @@
#!/usr/bin/env python3
"""
archivebox archiveresult <action> [args...] [--filters]
Manage ArchiveResult records (plugin extraction results).
Actions:
create - Emit plugin extraction request records for Snapshots
list - List ArchiveResults as JSONL (with optional filters)
update - Update ArchiveResults from stdin JSONL
delete - Delete ArchiveResults from stdin JSONL
Examples:
# Emit extraction requests; `archivebox run` schedules their parent snapshots
archivebox snapshot list --status=queued | archivebox archiveresult create
archivebox archiveresult create --plugin=screenshot --snapshot-id=<uuid>
# List with filters
archivebox archiveresult list --status=failed
archivebox archiveresult list --plugin=screenshot --status=succeeded
# Delete
archivebox archiveresult list --plugin=singlefile | archivebox archiveresult delete --yes
# Re-run failed extractions
archivebox archiveresult list --status=failed | archivebox run
"""
__package__ = "archivebox.cli"
__command__ = "archivebox archiveresult"
import sys
import rich_click as click
from rich import print as rprint
from archivebox.cli.cli_util import apply_filters
def build_archiveresult_request(snapshot_id: str, plugin: str, hook_name: str = "", status: str = "queued") -> dict:
return {
"type": "ArchiveResult",
"snapshot_id": str(snapshot_id),
"plugin": plugin,
"hook_name": hook_name,
"status": status,
}
# =============================================================================
# CREATE
# =============================================================================
def create_archiveresults(
snapshot_id: str | None = None,
plugin: str | None = None,
status: str = "queued",
) -> int:
"""
Create ArchiveResult request records for Snapshots.
Reads Snapshot records from stdin and emits ArchiveResult request JSONL.
Pass-through: Non-Snapshot/ArchiveResult records are output unchanged.
If --plugin is specified, only emits requests for that plugin.
Otherwise, emits requests for all enabled snapshot hooks.
Exit codes:
0: Success
1: Failure
"""
from archivebox.config.common import get_config
from archivebox.plugins.hooks import discover_hooks
from archivebox.misc.jsonl import read_stdin, write_record, TYPE_SNAPSHOT, TYPE_ARCHIVERESULT
from archivebox.core.models import Snapshot
is_tty = sys.stdout.isatty()
# If snapshot_id provided directly, use that
if snapshot_id:
try:
snapshots = [Snapshot.objects.get(id=snapshot_id)]
pass_through_records = []
except Snapshot.DoesNotExist:
rprint(f"[red]Snapshot not found: {snapshot_id}[/red]", file=sys.stderr)
return 1
else:
# Read from stdin
records = list(read_stdin())
if not records:
rprint("[yellow]No Snapshot records provided via stdin[/yellow]", file=sys.stderr)
return 1
# Separate snapshot records from pass-through records
snapshot_ids = []
pass_through_records = []
for record in records:
record_type = record.get("type", "")
if record_type == TYPE_SNAPSHOT:
# Pass through the Snapshot record itself
pass_through_records.append(record)
if record.get("id"):
snapshot_ids.append(record["id"])
elif record_type == TYPE_ARCHIVERESULT:
# ArchiveResult records: pass through if they have an id
if record.get("id"):
pass_through_records.append(record)
# If no id, we could create it, but for now just pass through
else:
pass_through_records.append(record)
elif record_type:
# Other typed records (Crawl, Tag, etc): pass through
pass_through_records.append(record)
elif record.get("id"):
# Untyped record with id - assume it's a snapshot ID
snapshot_ids.append(record["id"])
# Output pass-through records first
if not is_tty:
for record in pass_through_records:
write_record(record)
if not snapshot_ids:
if pass_through_records:
rprint(f"[dim]Passed through {len(pass_through_records)} records, no new snapshots to process[/dim]", file=sys.stderr)
return 0
rprint("[yellow]No valid Snapshot IDs in input[/yellow]", file=sys.stderr)
return 1
snapshots = list(Snapshot.objects.filter(id__in=snapshot_ids))
if not snapshots:
rprint("[yellow]No matching snapshots found[/yellow]", file=sys.stderr)
return 0 if pass_through_records else 1
created_count = 0
for snapshot in snapshots:
config = get_config(crawl=snapshot.crawl, snapshot=snapshot)
hooks = [
hook
for hook in discover_hooks("Snapshot", filter_disabled=not plugin, config=config)
if not plugin or hook.parent.name == plugin
]
for hook_path in hooks:
hook_name = hook_path.stem
plugin_name = hook_path.parent.name
if not is_tty:
write_record(build_archiveresult_request(snapshot.id, plugin_name, hook_name=hook_name, status=status))
created_count += 1
rprint(f"[green]Created {created_count} extraction request records[/green]", file=sys.stderr)
return 0
# =============================================================================
# LIST
# =============================================================================
def list_archiveresults(
status: str | None = None,
plugin: str | None = None,
snapshot_id: str | None = None,
limit: int | None = None,
) -> int:
"""
List ArchiveResults as JSONL with optional filters.
Exit codes:
0: Success (even if no results)
"""
from archivebox.misc.jsonl import write_record
from archivebox.core.models import ArchiveResult
is_tty = sys.stdout.isatty()
queryset = ArchiveResult.objects.all().order_by("-start_ts")
# Apply filters
filter_kwargs = {
"status": status,
"plugin": plugin,
"snapshot_id": snapshot_id,
}
queryset = apply_filters(queryset, filter_kwargs, limit=limit)
count = 0
for result in queryset:
if is_tty:
status_color = {
"queued": "yellow",
"started": "blue",
"succeeded": "green",
"failed": "red",
"skipped": "dim",
"noresults": "dim",
"backoff": "magenta",
}.get(result.status, "dim")
rprint(
f"[{status_color}]{result.status:10}[/{status_color}] {result.plugin:15} [dim]{result.id}[/dim] {result.snapshot.url[:40]}",
)
else:
write_record(result.to_json())
count += 1
rprint(f"[dim]Listed {count} archive results[/dim]", file=sys.stderr)
return 0
# =============================================================================
# UPDATE
# =============================================================================
def update_archiveresults(
status: str | None = None,
) -> int:
"""
Update ArchiveResults from stdin JSONL.
Reads ArchiveResult records from stdin and applies updates.
Uses PATCH semantics - only specified fields are updated.
Exit codes:
0: Success
1: No input or error
"""
from archivebox.misc.jsonl import read_stdin, write_record
from archivebox.core.models import ArchiveResult
is_tty = sys.stdout.isatty()
records = list(read_stdin())
if not records:
rprint("[yellow]No records provided via stdin[/yellow]", file=sys.stderr)
return 1
updated_count = 0
for record in records:
result_id = record.get("id")
if not result_id:
continue
try:
result = ArchiveResult.objects.get(id=result_id)
# Apply updates from CLI flags
if status:
result.status = status
result.save()
updated_count += 1
if not is_tty:
write_record(result.to_json())
except ArchiveResult.DoesNotExist:
rprint(f"[yellow]ArchiveResult not found: {result_id}[/yellow]", file=sys.stderr)
continue
rprint(f"[green]Updated {updated_count} archive results[/green]", file=sys.stderr)
return 0
# =============================================================================
# DELETE
# =============================================================================
def delete_archiveresults(yes: bool = False, dry_run: bool = False) -> int:
"""
Delete ArchiveResults from stdin JSONL.
Requires --yes flag to confirm deletion.
Exit codes:
0: Success
1: No input or missing --yes flag
"""
from archivebox.misc.jsonl import read_stdin
from archivebox.core.models import ArchiveResult
records = list(read_stdin())
if not records:
rprint("[yellow]No records provided via stdin[/yellow]", file=sys.stderr)
return 1
result_ids = [r.get("id") for r in records if r.get("id")]
if not result_ids:
rprint("[yellow]No valid archive result IDs in input[/yellow]", file=sys.stderr)
return 1
results = ArchiveResult.objects.filter(id__in=result_ids)
count = results.count()
if count == 0:
rprint("[yellow]No matching archive results found[/yellow]", file=sys.stderr)
return 0
if dry_run:
rprint(f"[yellow]Would delete {count} archive results (dry run)[/yellow]", file=sys.stderr)
for result in results[:10]:
rprint(f" [dim]{result.id}[/dim] {result.plugin} {result.snapshot.url[:40]}", file=sys.stderr)
if count > 10:
rprint(f" ... and {count - 10} more", file=sys.stderr)
return 0
if not yes:
rprint("[red]Use --yes to confirm deletion[/red]", file=sys.stderr)
return 1
# Perform deletion
deleted_count, _ = results.delete()
rprint(f"[green]Deleted {deleted_count} archive results[/green]", file=sys.stderr)
return 0
# =============================================================================
# CLI Commands
# =============================================================================
@click.group()
def main():
"""Manage ArchiveResult records (plugin extraction results)."""
pass
@main.command("create")
@click.option("--snapshot-id", help="Snapshot ID to create results for")
@click.option("--plugin", "-p", help="Plugin name (e.g., screenshot, singlefile)")
@click.option("--status", "-s", default="queued", help="Initial status (default: queued)")
def create_cmd(snapshot_id: str | None, plugin: str | None, status: str):
"""Emit Snapshot plugin extraction requests as JSONL."""
sys.exit(create_archiveresults(snapshot_id=snapshot_id, plugin=plugin, status=status))
@main.command("list")
@click.option("--status", "-s", help="Filter by status (queued, started, succeeded, failed, skipped)")
@click.option("--plugin", "-p", help="Filter by plugin name")
@click.option("--snapshot-id", help="Filter by snapshot ID")
@click.option("--limit", "-n", type=int, help="Limit number of results")
def list_cmd(
status: str | None,
plugin: str | None,
snapshot_id: str | None,
limit: int | None,
):
"""List ArchiveResults as JSONL."""
sys.exit(
list_archiveresults(
status=status,
plugin=plugin,
snapshot_id=snapshot_id,
limit=limit,
),
)
@main.command("update")
@click.option("--status", "-s", help="Set status")
def update_cmd(status: str | None):
"""Update ArchiveResults from stdin JSONL."""
sys.exit(update_archiveresults(status=status))
@main.command("delete")
@click.option("--yes", "-y", is_flag=True, help="Confirm deletion")
@click.option("--dry-run", is_flag=True, help="Show what would be deleted")
def delete_cmd(yes: bool, dry_run: bool):
"""Delete ArchiveResults from stdin JSONL."""
sys.exit(delete_archiveresults(yes=yes, dry_run=dry_run))
if __name__ == "__main__":
main()
-319
View File
@@ -1,319 +0,0 @@
#!/usr/bin/env python3
"""
archivebox binary <action> [args...] [--filters]
Manage Binary records (detected executables like chrome, wget, etc.).
Actions:
create - Create/register a Binary
list - List Binaries as JSONL (with optional filters)
update - Update Binaries from stdin JSONL
delete - Delete Binaries from stdin JSONL
Examples:
# List all binaries
archivebox binary list
# List specific binary
archivebox binary list --name=chrome
# List binaries with specific version
archivebox binary list --version__icontains=120
# Delete old binary entries
archivebox binary list --name=chrome | archivebox binary delete --yes
"""
__package__ = "archivebox.cli"
__command__ = "archivebox binary"
import sys
import rich_click as click
from rich import print as rprint
from archivebox.cli.cli_util import apply_filters
# =============================================================================
# CREATE
# =============================================================================
def create_binary(
name: str,
abspath: str,
version: str = "",
) -> int:
"""
Create/register a Binary.
Exit codes:
0: Success
1: Failure
"""
from archivebox.misc.jsonl import write_record
from archivebox.machine.models import Binary
is_tty = sys.stdout.isatty()
if not name or not abspath:
rprint("[red]Both --name and --abspath are required[/red]", file=sys.stderr)
return 1
try:
from archivebox.machine.models import Machine
machine = Machine.current()
created = not Binary.objects.filter(
machine=machine,
name=name,
abspath=abspath,
version=version,
).exists()
# Mirror the Binary model lifecycle used elsewhere in the system so CLI
# records are owned by the current machine and can be safely piped into
# `archivebox run` without creating invalid rows missing machine_id.
binary = Binary.from_json(
{
"name": name,
"abspath": abspath,
"version": version,
"binproviders": "env",
"binprovider": "env",
},
)
if binary is None:
raise ValueError("failed to create binary record")
if not is_tty:
write_record(binary.to_json())
if created:
rprint(f"[green]Created binary: {name} at {abspath}[/green]", file=sys.stderr)
else:
rprint(f"[dim]Binary already exists: {name} at {abspath}[/dim]", file=sys.stderr)
return 0
except Exception as e:
rprint(f"[red]Error creating binary: {e}[/red]", file=sys.stderr)
return 1
# =============================================================================
# LIST
# =============================================================================
def list_binaries(
name: str | None = None,
abspath__icontains: str | None = None,
version__icontains: str | None = None,
limit: int | None = None,
) -> int:
"""
List Binaries as JSONL with optional filters.
Exit codes:
0: Success (even if no results)
"""
from archivebox.misc.jsonl import write_record
from archivebox.machine.models import Binary
is_tty = sys.stdout.isatty()
queryset = Binary.objects.all().order_by("name", "-modified_at", "-created_at")
# Apply filters
filter_kwargs = {
"name": name,
"abspath__icontains": abspath__icontains,
"version__icontains": version__icontains,
}
queryset = apply_filters(queryset, filter_kwargs, limit=limit)
count = 0
for binary in queryset:
if is_tty:
rprint(f"[cyan]{binary.name:20}[/cyan] [dim]{binary.version:15}[/dim] {binary.abspath}")
else:
write_record(binary.to_json())
count += 1
rprint(f"[dim]Listed {count} binaries[/dim]", file=sys.stderr)
return 0
# =============================================================================
# UPDATE
# =============================================================================
def update_binaries(
version: str | None = None,
abspath: str | None = None,
) -> int:
"""
Update Binaries from stdin JSONL.
Reads Binary records from stdin and applies updates.
Uses PATCH semantics - only specified fields are updated.
Exit codes:
0: Success
1: No input or error
"""
from archivebox.misc.jsonl import read_stdin, write_record
from archivebox.machine.models import Binary
is_tty = sys.stdout.isatty()
records = list(read_stdin())
if not records:
rprint("[yellow]No records provided via stdin[/yellow]", file=sys.stderr)
return 1
updated_count = 0
for record in records:
binary_id = record.get("id")
if not binary_id:
continue
try:
binary = Binary.objects.get(id=binary_id)
# Apply updates from CLI flags
if version:
binary.version = version
if abspath:
binary.abspath = abspath
binary.save()
updated_count += 1
if not is_tty:
write_record(binary.to_json())
except Binary.DoesNotExist:
rprint(f"[yellow]Binary not found: {binary_id}[/yellow]", file=sys.stderr)
continue
rprint(f"[green]Updated {updated_count} binaries[/green]", file=sys.stderr)
return 0
# =============================================================================
# DELETE
# =============================================================================
def delete_binaries(yes: bool = False, dry_run: bool = False) -> int:
"""
Delete Binaries from stdin JSONL.
Requires --yes flag to confirm deletion.
Exit codes:
0: Success
1: No input or missing --yes flag
"""
from archivebox.misc.jsonl import read_stdin
from archivebox.machine.models import Binary
records = list(read_stdin())
if not records:
rprint("[yellow]No records provided via stdin[/yellow]", file=sys.stderr)
return 1
binary_ids = [r.get("id") for r in records if r.get("id")]
if not binary_ids:
rprint("[yellow]No valid binary IDs in input[/yellow]", file=sys.stderr)
return 1
binaries = Binary.objects.filter(id__in=binary_ids)
count = binaries.count()
if count == 0:
rprint("[yellow]No matching binaries found[/yellow]", file=sys.stderr)
return 0
if dry_run:
rprint(f"[yellow]Would delete {count} binaries (dry run)[/yellow]", file=sys.stderr)
for binary in binaries:
rprint(f" {binary.name} {binary.abspath}", file=sys.stderr)
return 0
if not yes:
rprint("[red]Use --yes to confirm deletion[/red]", file=sys.stderr)
return 1
# Perform deletion
deleted_count, _ = binaries.delete()
rprint(f"[green]Deleted {deleted_count} binaries[/green]", file=sys.stderr)
return 0
# =============================================================================
# CLI Commands
# =============================================================================
@click.group()
def main():
"""Manage Binary records (detected executables)."""
pass
@main.command("create")
@click.option("--name", "-n", required=True, help="Binary name (e.g., chrome, wget)")
@click.option("--abspath", "-p", required=True, help="Absolute path to binary")
@click.option("--version", "-v", default="", help="Binary version")
def create_cmd(name: str, abspath: str, version: str):
"""Create/register a Binary."""
sys.exit(create_binary(name=name, abspath=abspath, version=version))
@main.command("list")
@click.option("--name", "-n", help="Filter by name")
@click.option("--abspath__icontains", help="Filter by path contains")
@click.option("--version__icontains", help="Filter by version contains")
@click.option("--limit", type=int, help="Limit number of results")
def list_cmd(
name: str | None,
abspath__icontains: str | None,
version__icontains: str | None,
limit: int | None,
):
"""List Binaries as JSONL."""
sys.exit(
list_binaries(
name=name,
abspath__icontains=abspath__icontains,
version__icontains=version__icontains,
limit=limit,
),
)
@main.command("update")
@click.option("--version", "-v", help="Set version")
@click.option("--abspath", "-p", help="Set path")
def update_cmd(version: str | None, abspath: str | None):
"""Update Binaries from stdin JSONL."""
sys.exit(update_binaries(version=version, abspath=abspath))
@main.command("delete")
@click.option("--yes", "-y", is_flag=True, help="Confirm deletion")
@click.option("--dry-run", is_flag=True, help="Show what would be deleted")
def delete_cmd(yes: bool, dry_run: bool):
"""Delete Binaries from stdin JSONL."""
sys.exit(delete_binaries(yes=yes, dry_run=dry_run))
if __name__ == "__main__":
main()
+60 -188
View File
@@ -1,199 +1,71 @@
#!/usr/bin/env python3
__package__ = "archivebox.cli"
__package__ = 'archivebox.cli'
__command__ = 'archivebox config'
import sys
import toml
import rich_click as click
from rich import print
import argparse
from pathlib import Path
from archivebox.misc.util import docstring, enforce_types
from archivebox.misc.toml_util import CustomTOMLEncoder
from typing import Optional, List, IO
from archivebox.misc.util import docstring
from archivebox.config import DATA_DIR
from ..main import config
from ..logging_util import SmartFormatter, accept_stdin
def _format_toml(config: dict) -> str:
return toml.dumps(config, encoder=CustomTOMLEncoder()).strip().replace("\n\n", "\n")
@enforce_types
def config(
*keys,
get: bool = False,
set: bool = False,
search: bool = False,
reset: bool = False,
**kwargs,
) -> None:
"""Get and set your ArchiveBox project configuration values"""
from archivebox.misc.checks import check_data_folder
from archivebox.misc.logging_util import printable_config
from abx_plugins.plugins.base.utils import resolve_alias
from archivebox.config.collection import write_config_file
from archivebox.config import CONSTANTS_CONFIG
from archivebox.config.common import ArchiveBoxConfig, get_config, get_all_configs
from archivebox.plugins.discovery import discover_plugin_configs
check_data_folder()
FLAT_CONFIG = get_config().as_dict()
runtime_derived_keys = ArchiveBoxConfig.runtime_derived_config_keys()
readonly_config = {key: val for key, val in CONSTANTS_CONFIG.items() if key.isupper() and isinstance(val, Path)}
writable_config = {key: val for key, val in FLAT_CONFIG.items() if key not in runtime_derived_keys and key not in readonly_config}
readable_config = {**writable_config, **readonly_config}
CONFIGS = get_all_configs()
plugin_schemas = {
plugin_name: schema.get("properties", {}) for plugin_name, schema in discover_plugin_configs().items() if isinstance(schema, dict)
}
core_config_aliases = {
alias.upper(): field_name
for field_name, field in ArchiveBoxConfig.model_fields.items()
for alias in (field_name, str(field.alias or ""))
if alias
}
config_options: list[str] = list(kwargs.pop("key=value", []) or keys or [f"{key}={val}" for key, val in kwargs.items()])
no_args = not (get or set or reset or config_options)
matching_config = {}
if search:
if config_options:
search_terms = [key.strip().lower() for key in config_options]
for existing_key, value in readable_config.items():
if any(term in existing_key.lower() or term in str(value).lower() for term in search_terms):
matching_config[existing_key] = value
for alias, key in core_config_aliases.items():
if key in readable_config and any(term in alias.lower() for term in search_terms):
matching_config[key] = readable_config[key]
for schema in plugin_schemas.values():
for key, metadata in schema.items():
if key in readable_config and any(term in key.lower() or term in str(metadata).lower() for term in search_terms):
matching_config[key] = readable_config[key]
print(printable_config(matching_config))
raise SystemExit(not matching_config)
elif get or no_args:
if config_options:
config_options = [
core_config_aliases.get(key.upper().strip()) or resolve_alias(key.upper().strip(), plugin_schemas) for key in config_options
]
matching_config = {key: readable_config[key] for key in config_options if key in readable_config}
failed_config = [key for key in config_options if key not in readable_config]
if failed_config:
print("\n[red][X] These options failed to get[/red]")
print(" {}".format("\n ".join(config_options)))
raise SystemExit(1)
else:
matching_config = readable_config
# Display core config sections
for config_section in CONFIGS.values():
section_header = config_section.toml_section_header
if isinstance(section_header, str) and section_header:
print(f"[grey53]\\[{section_header}][/grey53]")
else:
print("[grey53]\\[CONSTANTS] # (read-only)[/grey53]")
kv_in_section = {key: val for key, val in dict(config_section).items() if key in matching_config}
print(_format_toml(kv_in_section))
print("[grey53]################################################################[/grey53]")
readonly_keys = {key: val for key, val in readonly_config.items() if key in matching_config}
if readonly_keys:
print("[grey53]\\[CONSTANTS] # (read-only)[/grey53]")
print(_format_toml(readonly_keys))
print("[grey53]################################################################[/grey53]")
plugin_keys = {}
# Collect all plugin config keys
for schema in plugin_schemas.values():
for key in schema.keys():
if key in matching_config and key in writable_config:
plugin_keys[key] = matching_config[key]
# Display all plugin config in single [PLUGINS] section
if plugin_keys:
print("[grey53]\\[PLUGINS][/grey53]")
print(_format_toml(plugin_keys))
print("[grey53]################################################################[/grey53]")
raise SystemExit(not matching_config)
elif set:
new_config = {}
failed_options = []
for line in config_options:
if line.startswith("#") or not line.strip():
continue
if "=" not in line:
print("[red][X] Config KEY=VALUE must have an = sign in it[/red]")
print(f" {line}")
raise SystemExit(2)
raw_key, val = line.split("=", 1)
raw_key = raw_key.upper().strip()
key = core_config_aliases.get(raw_key) or resolve_alias(raw_key, plugin_schemas)
if key != raw_key:
print(
f"[yellow][i] Note: The config option {raw_key} has been renamed to {key}, please use the new name going forwards.[/yellow]",
)
if key in writable_config:
new_config[key] = val.strip()
else:
failed_options.append(line)
if new_config:
before = FLAT_CONFIG
matching_config = write_config_file(new_config)
after = get_config().as_dict()
print(printable_config(matching_config))
side_effect_changes = {}
for key, val in after.items():
if key in FLAT_CONFIG and (str(before[key]) != str(after[key])) and (key not in matching_config):
side_effect_changes[key] = after[key]
if side_effect_changes:
print(file=sys.stderr)
print("[yellow][i] Note: This change also affected these other options that depended on it:[/yellow]", file=sys.stderr)
print(" {}".format(printable_config(side_effect_changes, prefix=" ")), file=sys.stderr)
if failed_options:
print()
print("[red][X] These options failed to set (check for typos):[/red]")
print(" {}".format("\n ".join(failed_options)))
raise SystemExit(1)
elif reset:
print("[red][X] This command is not implemented yet.[/red]")
print(" Please manually remove the relevant lines from your config file:")
raise SystemExit(2)
else:
print("[red][X] You must pass either --get or --set, or no arguments to get the whole config.[/red]")
print(" archivebox config")
print(" archivebox config --get SOME_KEY")
print(" archivebox config --set SOME_KEY=SOME_VALUE")
raise SystemExit(2)
@click.command()
@click.option("--search", is_flag=True, help="Search config KEYs, VALUEs, and ALIASES for the given term")
@click.option("--get", is_flag=True, help="Get the value for the given config KEYs")
@click.option("--set", is_flag=True, help="Set the given KEY=VALUE config values")
@click.option("--reset", is_flag=True, help="Reset the given KEY config values to their defaults")
@click.argument("KEY=VALUE", nargs=-1, type=str)
@docstring(config.__doc__)
def main(**kwargs) -> None:
config(**kwargs)
def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional[str]=None) -> None:
parser = argparse.ArgumentParser(
prog=__command__,
description=config.__doc__,
add_help=True,
formatter_class=SmartFormatter,
)
group = parser.add_mutually_exclusive_group()
parser.add_argument(
'--search',
action='store_true',
help="Search config KEYs, VALUEs, and ALIASES for the given term",
)
group.add_argument(
'--get', #'-g',
action='store_true',
help="Get the value for the given config KEYs",
)
group.add_argument(
'--set', #'-s',
action='store_true',
help="Set the given KEY=VALUE config values",
)
group.add_argument(
'--reset', #'-s',
action='store_true',
help="Reset the given KEY config values to their defaults",
)
parser.add_argument(
'config_options',
nargs='*',
type=str,
help='KEY or KEY=VALUE formatted config values to get or set',
)
command = parser.parse_args(args or ())
config_options_str = ''
if not command.config_options:
config_options_str = accept_stdin(stdin)
config(
config_options_str=config_options_str,
config_options=command.config_options,
search=command.search,
get=command.get,
set=command.set,
reset=command.reset,
out_dir=Path(pwd) if pwd else DATA_DIR,
)
if __name__ == "__main__":
main()
if __name__ == '__main__':
main(args=sys.argv[1:], stdin=sys.stdin)
-406
View File
@@ -1,406 +0,0 @@
#!/usr/bin/env python3
"""
archivebox crawl <action> [args...] [--filters]
Manage Crawl records.
Actions:
create - Create Crawl jobs from URLs
list - List Crawls as JSONL (with optional filters)
update - Update Crawls from stdin JSONL
delete - Delete Crawls from stdin JSONL
Examples:
# Create
archivebox crawl create https://example.com https://foo.com --depth=1
archivebox crawl create --tag=news https://example.com
# List with filters
archivebox crawl list --status=queued
archivebox crawl list --urls__icontains=example.com
# Update
archivebox crawl list --status=started | archivebox crawl update --status=queued
# Delete
archivebox crawl list --urls__icontains=spam.com | archivebox crawl delete --yes
# Full pipeline
archivebox crawl create https://example.com | archivebox snapshot create | archivebox run
"""
__package__ = "archivebox.cli"
__command__ = "archivebox crawl"
import sys
from collections.abc import Iterable
import rich_click as click
from rich import print as rprint
from archivebox.cli.cli_util import apply_filters
# =============================================================================
# CREATE
# =============================================================================
def create_crawl(
urls: Iterable[str],
depth: int = 0,
tag: str = "",
status: str = "queued",
created_by_id: int | None = None,
) -> int:
"""
Create a Crawl job from URLs.
Takes URLs as args or stdin, creates one Crawl with all URLs, outputs JSONL.
Pass-through: Records that are not URLs are output unchanged (for piping).
Exit codes:
0: Success
1: Failure
"""
from archivebox.misc.jsonl import read_args_or_stdin, write_record, TYPE_CRAWL
from archivebox.misc.util import validate_url
from archivebox.base_models.models import get_or_create_system_user_pk
from archivebox.crawls.models import Crawl
created_by_id = created_by_id or get_or_create_system_user_pk()
is_tty = sys.stdout.isatty()
# Collect all input records
records = list(read_args_or_stdin(urls))
if not records:
rprint("[yellow]No URLs provided. Pass URLs as arguments or via stdin.[/yellow]", file=sys.stderr)
return 1
# Separate pass-through records from URL records
url_list = []
pass_through_records = []
for record in records:
record_type = record.get("type", "")
# Pass-through: output records that aren't URL/Crawl types
if record_type and record_type != TYPE_CRAWL and not record.get("url") and not record.get("urls"):
pass_through_records.append(record)
continue
# Handle existing Crawl records (just pass through with id)
if record_type == TYPE_CRAWL and record.get("id"):
pass_through_records.append(record)
continue
# Collect URLs
url = record.get("url")
if url:
try:
url_list.append(validate_url(str(url)))
except ValueError as err:
rprint(f"[red]Invalid URL: {err}[/red]", file=sys.stderr)
return 1
# Handle 'urls' field (newline-separated)
urls_field = record.get("urls")
if urls_field:
for line in urls_field.split("\n"):
line = line.strip()
if line and not line.startswith("#"):
try:
url_list.append(validate_url(line))
except ValueError as err:
rprint(f"[red]Invalid URL: {err}[/red]", file=sys.stderr)
return 1
# Output pass-through records first
if not is_tty:
for record in pass_through_records:
write_record(record)
if not url_list:
if pass_through_records:
# If we had pass-through records but no URLs, that's OK
rprint(f"[dim]Passed through {len(pass_through_records)} records, no new URLs[/dim]", file=sys.stderr)
return 0
rprint("[red]No valid URLs found[/red]", file=sys.stderr)
return 1
try:
# Build crawl record with all URLs as newline-separated string
crawl_record = {
"urls": "\n".join(url_list),
"max_depth": depth,
"tags_str": tag,
"status": status,
"label": "",
}
crawl = Crawl.from_json(crawl_record, overrides={"created_by_id": created_by_id})
if not crawl:
rprint("[red]Failed to create crawl[/red]", file=sys.stderr)
return 1
# Output JSONL record (only when piped)
if not is_tty:
write_record(crawl.to_json())
rprint(f"[green]Created crawl with {len(url_list)} URLs[/green]", file=sys.stderr)
# If TTY, show human-readable output
if is_tty:
rprint(f" [dim]{crawl.id}[/dim]", file=sys.stderr)
for url in url_list[:5]: # Show first 5 URLs
rprint(f" {url[:70]}", file=sys.stderr)
if len(url_list) > 5:
rprint(f" ... and {len(url_list) - 5} more", file=sys.stderr)
return 0
except Exception as e:
rprint(f"[red]Error creating crawl: {e}[/red]", file=sys.stderr)
return 1
# =============================================================================
# LIST
# =============================================================================
def list_crawls(
status: str | None = None,
urls__icontains: str | None = None,
max_depth: int | None = None,
limit: int | None = None,
) -> int:
"""
List Crawls as JSONL with optional filters.
Exit codes:
0: Success (even if no results)
"""
from archivebox.misc.jsonl import write_record
from archivebox.crawls.models import Crawl
is_tty = sys.stdout.isatty()
queryset = Crawl.objects.all().order_by("-created_at")
# Apply filters
filter_kwargs = {
"status": status,
"urls__icontains": urls__icontains,
"max_depth": max_depth,
}
queryset = apply_filters(queryset, filter_kwargs, limit=limit)
count = 0
for crawl in queryset:
if is_tty:
status_color = {
"queued": "yellow",
"started": "blue",
"sealed": "green",
}.get(crawl.status, "dim")
url_preview = crawl.urls[:50].replace("\n", " ")
rprint(f"[{status_color}]{crawl.status:8}[/{status_color}] [dim]{crawl.id}[/dim] {url_preview}...")
else:
write_record(crawl.to_json())
count += 1
rprint(f"[dim]Listed {count} crawls[/dim]", file=sys.stderr)
return 0
# =============================================================================
# UPDATE
# =============================================================================
def update_crawls(
status: str | None = None,
max_depth: int | None = None,
) -> int:
"""
Update Crawls from stdin JSONL.
Reads Crawl records from stdin and applies updates.
Uses PATCH semantics - only specified fields are updated.
Exit codes:
0: Success
1: No input or error
"""
from django.utils import timezone
from archivebox.misc.jsonl import read_stdin, write_record
from archivebox.crawls.models import Crawl
is_tty = sys.stdout.isatty()
records = list(read_stdin())
if not records:
rprint("[yellow]No records provided via stdin[/yellow]", file=sys.stderr)
return 1
updated_count = 0
for record in records:
crawl_id = record.get("id")
if not crawl_id:
continue
try:
crawl = Crawl.objects.get(id=crawl_id)
if status:
if status not in Crawl.StatusChoices.values:
rprint(f"[red]Invalid crawl status: {status}[/red]", file=sys.stderr)
continue
if status == Crawl.StatusChoices.SEALED:
crawl.cancel()
elif status == Crawl.StatusChoices.PAUSED:
crawl.pause()
elif status == Crawl.StatusChoices.QUEUED:
if crawl.status == Crawl.StatusChoices.PAUSED:
crawl.resume()
else:
crawl.update_and_requeue(status=Crawl.StatusChoices.QUEUED, retry_at=timezone.now())
elif status == Crawl.StatusChoices.STARTED:
crawl.update_and_requeue(status=Crawl.StatusChoices.STARTED, retry_at=timezone.now())
if max_depth is not None:
crawl.safe_update({"max_depth": max_depth, "modified_at": timezone.now()}, refresh=False)
crawl.max_depth = max_depth
elif not status:
crawl.safe_update({"modified_at": timezone.now()}, refresh=False)
updated_count += 1
if not is_tty:
crawl.refresh_from_db()
write_record(crawl.to_json())
except Crawl.DoesNotExist:
rprint(f"[yellow]Crawl not found: {crawl_id}[/yellow]", file=sys.stderr)
continue
rprint(f"[green]Updated {updated_count} crawls[/green]", file=sys.stderr)
return 0
# =============================================================================
# DELETE
# =============================================================================
def delete_crawls(yes: bool = False, dry_run: bool = False) -> int:
"""
Delete Crawls from stdin JSONL.
Requires --yes flag to confirm deletion.
Exit codes:
0: Success
1: No input or missing --yes flag
"""
from archivebox.misc.jsonl import read_stdin
from archivebox.crawls.models import Crawl
records = list(read_stdin())
if not records:
rprint("[yellow]No records provided via stdin[/yellow]", file=sys.stderr)
return 1
crawl_ids = [r.get("id") for r in records if r.get("id")]
if not crawl_ids:
rprint("[yellow]No valid crawl IDs in input[/yellow]", file=sys.stderr)
return 1
crawls = Crawl.objects.filter(id__in=crawl_ids)
count = crawls.count()
if count == 0:
rprint("[yellow]No matching crawls found[/yellow]", file=sys.stderr)
return 0
if dry_run:
rprint(f"[yellow]Would delete {count} crawls (dry run)[/yellow]", file=sys.stderr)
for crawl in crawls:
url_preview = crawl.urls[:50].replace("\n", " ")
rprint(f" [dim]{crawl.id}[/dim] {url_preview}...", file=sys.stderr)
return 0
if not yes:
rprint("[red]Use --yes to confirm deletion[/red]", file=sys.stderr)
return 1
# Perform deletion
deleted_count, _ = crawls.delete()
rprint(f"[green]Deleted {deleted_count} crawls[/green]", file=sys.stderr)
return 0
# =============================================================================
# CLI Commands
# =============================================================================
@click.group()
def main():
"""Manage Crawl records."""
pass
@main.command("create")
@click.argument("urls", nargs=-1)
@click.option("--depth", "-d", type=int, default=0, help="Max crawl depth (default: 0)")
@click.option("--tag", "-t", default="", help="Comma-separated tags to add")
@click.option("--status", "-s", default="queued", help="Initial status (default: queued)")
def create_cmd(urls: tuple, depth: int, tag: str, status: str):
"""Create a Crawl job from URLs or stdin."""
sys.exit(create_crawl(urls, depth=depth, tag=tag, status=status))
@main.command("list")
@click.option("--status", "-s", help="Filter by status (queued, started, sealed)")
@click.option("--urls__icontains", help="Filter by URLs contains")
@click.option("--max-depth", type=int, help="Filter by max depth")
@click.option("--limit", "-n", type=int, help="Limit number of results")
def list_cmd(
status: str | None,
urls__icontains: str | None,
max_depth: int | None,
limit: int | None,
):
"""List Crawls as JSONL."""
sys.exit(
list_crawls(
status=status,
urls__icontains=urls__icontains,
max_depth=max_depth,
limit=limit,
),
)
@main.command("update")
@click.option("--status", "-s", help="Set status")
@click.option("--max-depth", type=int, help="Set max depth")
def update_cmd(status: str | None, max_depth: int | None):
"""Update Crawls from stdin JSONL."""
sys.exit(update_crawls(status=status, max_depth=max_depth))
@main.command("delete")
@click.option("--yes", "-y", is_flag=True, help="Confirm deletion")
@click.option("--dry-run", is_flag=True, help="Show what would be deleted")
def delete_cmd(yes: bool, dry_run: bool):
"""Delete Crawls from stdin JSONL."""
sys.exit(delete_crawls(yes=yes, dry_run=dry_run))
if __name__ == "__main__":
main()
-203
View File
@@ -1,203 +0,0 @@
#!/usr/bin/env python3
"""Run abx-dl snapshot hooks for existing ArchiveBox snapshots."""
__package__ = "archivebox.cli"
__command__ = "archivebox extract"
import sys
from collections import defaultdict
from contextlib import redirect_stdout
import rich_click as click
def _resolve_requests(records: list[dict], plugins: str) -> tuple[dict[str, set[str]], set[str]]:
"""Resolve CLI records to snapshot-level execution requests.
ArchiveResult input is accepted as a convenient reference to its parent
Snapshot and plugin. It is never reset or converted into queued work.
"""
from archivebox.api.v1_core import _uuid_ref_query
from archivebox.core.models import ArchiveResult, Snapshot
from archivebox.misc.jsonl import TYPE_ARCHIVERESULT
explicit_plugins = {name.strip() for name in plugins.split(",") if name.strip()}
requested: dict[str, set[str]] = defaultdict(set)
missing: set[str] = set()
for record in records:
record_type = record.get("type")
record_id = str(record.get("id") or "")
if record_type == TYPE_ARCHIVERESULT and record.get("snapshot_id"):
requested[str(record["snapshot_id"])].update(explicit_plugins or {str(record.get("plugin") or "")})
requested[str(record["snapshot_id"])].discard("")
continue
if record_type == TYPE_ARCHIVERESULT:
result = ArchiveResult.objects.filter(_uuid_ref_query("id", record_id)).only("snapshot_id", "plugin").first()
if result is not None:
requested[str(result.snapshot_id)].update(explicit_plugins or {result.plugin})
continue
snapshot_id = str(record.get("snapshot_id") or record_id or "")
snapshot = Snapshot.objects.filter(id=snapshot_id).only("id").first() if snapshot_id else None
if snapshot is None and record.get("url"):
snapshot = Snapshot.objects.filter(url=record["url"]).order_by("-created_at").only("id").first()
# Bare UUID CLI arguments are parsed as Snapshot records because the
# input layer cannot know which model owns them. Preserve the public
# convenience of passing an ArchiveResult ID by trying that reference
# only after the Snapshot lookup misses.
if snapshot is None and record_id and not record.get("url"):
result = ArchiveResult.objects.filter(_uuid_ref_query("id", record_id)).only("snapshot_id", "plugin").first()
if result is not None:
requested[str(result.snapshot_id)].update(explicit_plugins or {result.plugin})
continue
if snapshot is None:
missing.add(snapshot_id or str(record.get("url") or ""))
continue
requested[str(snapshot.id)].update(explicit_plugins)
return requested, missing
def _run_snapshot_requests(requested: dict[str, set[str]], *, wait: bool, show_progress: bool) -> int:
from django.utils import timezone
from rich import print as rprint
from archivebox.core.models import Snapshot
from archivebox.services.runner import run_crawl
snapshots = {str(snapshot.id): snapshot for snapshot in Snapshot.objects.filter(id__in=requested).select_related("crawl")}
if not snapshots:
rprint("[red]No snapshots to process[/red]", file=sys.stderr)
return 1
from archivebox.config.common import get_config
from archivebox.plugins.discovery import get_enabled_plugins
for snapshot_id, plugin_names in requested.items():
snapshot = snapshots.get(snapshot_id)
if snapshot is not None and not plugin_names:
plugin_names.update(get_enabled_plugins(config=get_config(crawl=snapshot.crawl, snapshot=snapshot)))
if wait and any("search_backend_sonic" in plugin_names for plugin_names in requested.values()):
from archivebox.core.takeover_util import ensure_daemon_stack
with redirect_stdout(sys.stderr):
ensure_daemon_stack(reason="Sonic snapshot indexing")
# Explicit extraction resumes open/paused snapshots at the snapshot level.
# Sealed snapshots stay sealed during targeted maintenance backfills.
if wait:
for snapshot in snapshots.values():
if snapshot.status != Snapshot.StatusChoices.SEALED:
snapshot.update_and_requeue(
status=Snapshot.StatusChoices.QUEUED,
retry_at=timezone.now(),
)
if not wait:
for snapshot_id, plugin_names in requested.items():
snapshot = snapshots.get(snapshot_id)
if snapshot is None:
continue
if plugin_names:
snapshot.schedule_plugin_run(plugin_names, when=timezone.now())
elif snapshot.status == Snapshot.StatusChoices.SEALED:
snapshot.update_and_requeue(retry_at=timezone.now())
else:
snapshot.update_and_requeue(
status=Snapshot.StatusChoices.QUEUED,
retry_at=timezone.now(),
)
if show_progress:
rprint(f"[blue]Queued {len(snapshots)} snapshots for extraction[/blue]", file=sys.stderr)
return 0
grouped: dict[tuple[str, tuple[str, ...]], list[str]] = defaultdict(list)
for snapshot_id, plugin_names in requested.items():
snapshot = snapshots.get(snapshot_id)
if snapshot is not None:
grouped[(str(snapshot.crawl_id), tuple(sorted(plugin_names)))].append(snapshot_id)
for (crawl_id, plugin_names), snapshot_ids in grouped.items():
run_crawl(
crawl_id,
snapshot_ids=sorted(snapshot_ids),
selected_plugins=list(plugin_names) or None,
show_progress=show_progress,
)
return 0
def run_plugins(
args: tuple,
records: list[dict] | None = None,
plugins: str = "",
wait: bool = True,
emit_results: bool = True,
show_progress: bool = True,
) -> int:
"""Execute selected plugins through the snapshot-level runner."""
from rich import print as rprint
from archivebox.core.models import Snapshot
from archivebox.misc.jsonl import read_args_or_stdin, write_record
if records is None:
records = list(read_args_or_stdin(args))
if not records:
rprint("[yellow]No snapshots provided. Pass snapshot IDs as arguments or via stdin.[/yellow]", file=sys.stderr)
return 1
requested, missing = _resolve_requests(records, plugins)
for value in sorted(missing):
rprint(f"[yellow]Snapshot or ArchiveResult not found: {value}[/yellow]", file=sys.stderr)
if not requested:
return 1
exit_code = _run_snapshot_requests(requested, wait=wait, show_progress=show_progress)
if exit_code or not emit_results:
return exit_code
is_tty = sys.stdout.isatty()
for snapshot in Snapshot.objects.filter(id__in=requested):
results = snapshot.archiveresult_set.all()
requested_plugins = requested[str(snapshot.id)]
if requested_plugins:
results = results.filter(plugin__in=requested_plugins)
for result in results:
if is_tty:
color = {"succeeded": "green", "failed": "red", "skipped": "yellow"}.get(result.status, "dim")
rprint(f" [{color}]{result.status}[/{color}] {result.plugin} → {result.output_str or ''}", file=sys.stderr)
else:
write_record(result.to_json())
return 0
def process_archiveresult_by_id(archiveresult_id: str) -> int:
"""Re-run the parent Snapshot plugin referenced by an ArchiveResult."""
return run_plugins((), records=[{"id": archiveresult_id}], wait=True)
@click.command()
@click.option("--plugins", "--plugin", "-p", default="", help="Comma-separated list of plugins to run")
@click.option("--wait/--no-wait", default=True, help="Wait for plugins to complete (default: wait)")
@click.argument("args", nargs=-1)
def main(plugins: str, wait: bool, args: tuple):
"""Run plugins on Snapshots; ArchiveResult IDs select their parent plugin."""
from archivebox.misc.jsonl import read_args_or_stdin
records = list(read_args_or_stdin(args))
if not records:
from rich import print as rprint
rprint("[yellow]No Snapshot IDs or ArchiveResult IDs provided. Pass as arguments or via stdin.[/yellow]", file=sys.stderr)
sys.exit(1)
sys.exit(run_plugins(args, records=records, plugins=plugins, wait=wait))
if __name__ == "__main__":
main()
+22 -148
View File
@@ -1,158 +1,32 @@
#!/usr/bin/env python3
__package__ = "archivebox.cli"
__command__ = "archivebox help"
import ast
import importlib.util
import os
__package__ = 'archivebox.cli'
__command__ = 'archivebox help'
import sys
from datetime import UTC, datetime
import argparse
from pathlib import Path
from typing import Optional, List, IO
import click
from rich import print
from rich.panel import Panel
from archivebox.misc.util import docstring
from archivebox.config import DATA_DIR
from ..main import help
from ..logging_util import SmartFormatter, reject_stdin
def _command_doc(cmd: str, import_path: str) -> str:
def first_doc_line(docstring: str | None) -> str:
lines = (docstring or "").splitlines()
return lines[0] if lines else ""
modname, _ = import_path.rsplit(".", 1)
spec = importlib.util.find_spec(modname)
if spec is None or spec.origin is None:
return ""
try:
tree = ast.parse(Path(spec.origin).read_text())
except OSError:
return ""
for name in (cmd, "main"):
for node in tree.body:
if isinstance(node, ast.FunctionDef) and node.name == name:
return first_doc_line(ast.get_docstring(node))
return first_doc_line(ast.get_docstring(tree))
def help() -> None:
"""Print the ArchiveBox help message and usage"""
from archivebox.cli import ArchiveBoxGroup
from archivebox.config import CONSTANTS
from archivebox.config.permissions import IN_DOCKER
version_msg = (
f"[dark_magenta]\\[{datetime.now(UTC):%Y-%m-%d %H:%M:%S}][/dark_magenta] "
f"[dark_red]ArchiveBox[/dark_red] [dark_goldenrod]v{CONSTANTS.VERSION}[/dark_goldenrod]: "
"[green4]archivebox [green3]help"
@docstring(help.__doc__)
def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional[str]=None) -> None:
parser = argparse.ArgumentParser(
prog=__command__,
description=help.__doc__,
add_help=True,
formatter_class=SmartFormatter,
)
print(Panel(version_msg), file=sys.stderr)
COMMANDS_HELP_TEXT = (
"\n ".join(f"[green]{cmd.ljust(20)}[/green] {_command_doc(cmd, path)}" for cmd, path in ArchiveBoxGroup.meta_commands.items())
+ "\n\n "
+ "\n ".join(f"[green]{cmd.ljust(20)}[/green] {_command_doc(cmd, path)}" for cmd, path in ArchiveBoxGroup.setup_commands.items())
+ "\n\n "
+ "\n ".join(
f"[green]{cmd.ljust(20)}[/green] {_command_doc(cmd, path)}" for cmd, path in ArchiveBoxGroup.archive_commands.items()
)
)
DOCKER_USAGE = (
"""
[dodger_blue3]Docker Usage:[/dodger_blue3]
[grey53]# using Docker Compose:[/grey53]
[blue]docker compose run[/blue] [dark_green]archivebox[/dark_green] [green]\\[command][/green] [green3][...args][/green3] [violet][--help][/violet] [grey53][--version][/grey53]
[grey53]# using Docker:[/grey53]
[blue]docker run[/blue] -v [light_slate_blue]$PWD:/data[/light_slate_blue] [grey53]-p 8000:8000[/grey53] -it [dark_green]archivebox/archivebox[/dark_green] [green]\\[command][/green] [green3][...args][/green3] [violet][--help][/violet] [grey53][--version][/grey53]
"""
if IN_DOCKER
else ""
)
DOCKER_DOCS = (
"\n [link=https://github.com/ArchiveBox/ArchiveBox/wiki/Docker#usage]https://github.com/ArchiveBox/ArchiveBox/wiki/Docker[/link]"
if IN_DOCKER
else ""
)
DOCKER_OUTSIDE_HINT = "\n [grey53]# outside of Docker:[/grey53]" if IN_DOCKER else ""
DOCKER_CMD_PREFIX = "[blue]docker ... [/blue]" if IN_DOCKER else ""
print(f"""{DOCKER_USAGE}
[deep_sky_blue4]Usage:[/deep_sky_blue4]{DOCKER_OUTSIDE_HINT}
[dark_green]archivebox[/dark_green] [green]\\[command][/green] [green3][...args][/green3] [violet][--help][/violet] [grey53][--version][/grey53]
[deep_sky_blue4]Commands:[/deep_sky_blue4]
{COMMANDS_HELP_TEXT}
[deep_sky_blue4]Documentation:[/deep_sky_blue4]
[link=https://github.com/ArchiveBox/ArchiveBox/wiki]https://github.com/ArchiveBox/ArchiveBox/wiki[/link]{DOCKER_DOCS}
[link=https://github.com/ArchiveBox/ArchiveBox/wiki/Usage#cli-usage]https://github.com/ArchiveBox/ArchiveBox/wiki/Usage[/link]
[link=https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration]https://github.com/ArchiveBox/ArchiveBox/wiki/Configuration[/link]
""")
if os.access(CONSTANTS.ARCHIVE_DIR, os.R_OK) and CONSTANTS.ARCHIVE_DIR.is_dir():
pretty_out_dir = str(CONSTANTS.DATA_DIR).replace(str(Path("~").expanduser()), "~")
EXAMPLE_USAGE = f"""
[light_slate_blue]DATA DIR[/light_slate_blue]: [yellow]{pretty_out_dir}[/yellow]
[violet]Hint:[/violet] [i]Common maintenance tasks:[/i]
[dark_green]archivebox[/dark_green] [green]init[/green] [grey53]# make sure database is up-to-date (safe to run multiple times)[/grey53]
[dark_green]archivebox[/dark_green] [green]install[/green] [grey53]# make sure plugins are up-to-date (wget, chrome, singlefile, etc.)[/grey53]
[dark_green]archivebox[/dark_green] [green]status[/green] [grey53]# get a health checkup report on your collection[/grey53]
[dark_green]archivebox[/dark_green] [green]update[/green] [grey53]# retry any previously failed or interrupted archiving tasks[/grey53]
[violet]Hint:[/violet] [i]More example usage:[/i]
[dark_green]archivebox[/dark_green] [green]add[/green] --depth=1 "https://example.com/some/page"
[dark_green]archivebox[/dark_green] [green]list[/green] --sort=timestamp --csv=timestamp,downloaded_at,url,title
[dark_green]archivebox[/dark_green] [green]schedule[/green] --every=day --depth=1 "https://example.com/some/feed.rss"
[dark_green]archivebox[/dark_green] [green]server[/green] [blue]0.0.0.0:8000[/blue] [grey53]# Start the Web UI / API server[/grey53]
"""
print(
Panel(
EXAMPLE_USAGE,
expand=False,
border_style="grey53",
title="[green3]:white_check_mark: A collection [light_slate_blue]DATA DIR[/light_slate_blue] is currently active[/green3]",
subtitle="Commands run inside this dir will only apply to this collection.",
),
)
else:
DATA_SETUP_HELP = "\n"
if IN_DOCKER:
DATA_SETUP_HELP += "[violet]Hint:[/violet] When using Docker, you need to mount a volume to use as your data dir:\n"
DATA_SETUP_HELP += " docker run [violet]-v /some/path/data:/data[/violet] archivebox/archivebox ...\n\n"
DATA_SETUP_HELP += "To load an [dark_blue]existing[/dark_blue] collection:\n"
DATA_SETUP_HELP += " 1. [green]cd[/green] ~/archivebox/data [grey53]# go into existing [light_slate_blue]DATA DIR[/light_slate_blue] (can be anywhere)[/grey53]\n"
DATA_SETUP_HELP += f" 2. {DOCKER_CMD_PREFIX}[dark_green]archivebox[/dark_green] [green]init[/green] [grey53]# migrate to latest version (safe to run multiple times)[/grey53]\n"
DATA_SETUP_HELP += f" 3. {DOCKER_CMD_PREFIX}[dark_green]archivebox[/dark_green] [green]install[/green] [grey53]# auto-update all plugins (wget, chrome, singlefile, etc.)[/grey53]\n"
DATA_SETUP_HELP += f" 4. {DOCKER_CMD_PREFIX}[dark_green]archivebox[/dark_green] [green]help[/green] [grey53]# ...get help with next steps... [/grey53]\n\n"
DATA_SETUP_HELP += "To start a [sea_green1]new[/sea_green1] collection:\n"
DATA_SETUP_HELP += " 1. [green]mkdir[/green] ~/archivebox/data [grey53]# create a new, empty [light_slate_blue]DATA DIR[/light_slate_blue] (can be anywhere)[/grey53]\n"
DATA_SETUP_HELP += " 2. [green]cd[/green] ~/archivebox/data [grey53]# cd into the new directory[/grey53]\n"
DATA_SETUP_HELP += f" 3. {DOCKER_CMD_PREFIX}[dark_green]archivebox[/dark_green] [green]init[/green] [grey53]# initialize ArchiveBox in the new data dir[/grey53]\n"
DATA_SETUP_HELP += f" 4. {DOCKER_CMD_PREFIX}[dark_green]archivebox[/dark_green] [green]install[/green] [grey53]# auto-install all plugins (wget, chrome, singlefile, etc.)[/grey53]\n"
DATA_SETUP_HELP += f" 5. {DOCKER_CMD_PREFIX}[dark_green]archivebox[/dark_green] [green]help[/green] [grey53]# ... get help with next steps... [/grey53]\n"
print(
Panel(
DATA_SETUP_HELP,
expand=False,
border_style="grey53",
title="[red]:cross_mark: No collection is currently active[/red]",
subtitle="All archivebox [green]commands[/green] should be run from inside a collection [light_slate_blue]DATA DIR[/light_slate_blue]",
),
)
parser.parse_args(args or ())
reject_stdin(__command__, stdin)
help(out_dir=Path(pwd) if pwd else DATA_DIR)
@click.command()
@click.option("--help", "-h", is_flag=True, help="Show help")
def main(**kwargs):
"""Print the ArchiveBox help message and usage"""
return help()
if __name__ == "__main__":
main()
if __name__ == '__main__':
main(args=sys.argv[1:], stdin=sys.stdin)
+46 -194
View File
@@ -1,205 +1,57 @@
#!/usr/bin/env python3
__package__ = "archivebox.cli"
__package__ = 'archivebox.cli'
__command__ = 'archivebox init'
import os
import sys
from pathlib import Path
import argparse
from rich import print
import rich_click as click
from typing import Optional, List, IO
from archivebox.misc.util import docstring, enforce_types
from archivebox.misc.util import docstring
from archivebox.config import DATA_DIR
from ..logging_util import SmartFormatter, reject_stdin
from ..main import init
def _display_data_path(path: Path, data_dir: Path) -> str:
path = Path(path).resolve()
data_dir = Path(data_dir).resolve()
try:
return f"./{path.relative_to(data_dir)}"
except ValueError:
return str(path)
@enforce_types
def init(force: bool = False, quick: bool = False, install: bool = False) -> None:
"""Initialize a new ArchiveBox collection in the current directory"""
from archivebox.misc.checks import check_not_inside_source_dir
check_not_inside_source_dir()
from archivebox.config import CONSTANTS, VERSION
from archivebox.config.common import get_config
from archivebox.config.collection import write_config_file
from archivebox.misc.db import apply_migrations, database_exists, ensure_database_ready
from archivebox.misc.checks import check_migrations
config = get_config()
is_empty = not len(set(os.listdir(CONSTANTS.DATA_DIR)) - CONSTANTS.ALLOWED_IN_DATA_DIR)
existing_index = database_exists()
if is_empty and not existing_index:
print(f"[turquoise4][+] Initializing a new ArchiveBox v{VERSION} collection...[/turquoise4]")
print("[green]----------------------------------------------------------------------[/green]")
elif existing_index:
# TODO: properly detect and print the existing version in current index as well
print(f"[green][*] Verifying and updating existing ArchiveBox collection to v{VERSION}...[/green]")
print("[green]----------------------------------------------------------------------[/green]")
else:
if force:
print("[red][!] This folder appears to already have files in it, but no index.sqlite3 is present.[/red]")
print("[red] Because --force was passed, ArchiveBox will initialize anyway (which may overwrite existing files).[/red]")
else:
print(
"[red][X] This folder appears to already have files in it, but no index.sqlite3 present.[/red]\n\n"
" You must run init in a completely empty directory, or an existing data folder.\n\n"
" [violet]Hint:[/violet] To import an existing data folder make sure to cd into the folder first, \n"
" then run and run 'archivebox init' to pick up where you left off.\n\n"
" (Always make sure your data folder is backed up first before updating ArchiveBox)",
)
raise SystemExit(2)
if existing_index:
print("\n[green][*] Verifying archive folder structure...[/green]")
else:
print("\n[green][+] Building archive folder structure...[/green]")
archive_path = _display_data_path(CONSTANTS.ARCHIVE_DIR, CONSTANTS.DATA_DIR)
sources_path = _display_data_path(CONSTANTS.SOURCES_DIR, CONSTANTS.DATA_DIR)
logs_path = _display_data_path(CONSTANTS.LOGS_DIR, CONSTANTS.DATA_DIR)
print(f" + {archive_path}, {sources_path}, {logs_path}...")
Path(CONSTANTS.SOURCES_DIR).mkdir(exist_ok=True)
CONSTANTS.ARCHIVE_DIR.mkdir(parents=True, exist_ok=True)
CONSTANTS.USERS_DIR.mkdir(parents=True, exist_ok=True)
Path(CONSTANTS.LOGS_DIR).mkdir(exist_ok=True)
for path in (Path(CONSTANTS.SOURCES_DIR), CONSTANTS.ARCHIVE_DIR, CONSTANTS.USERS_DIR, Path(CONSTANTS.LOGS_DIR)):
path.chmod(int(config.OUTPUT_PERMISSIONS, base=8) | 0o111)
print(f" + {_display_data_path(CONSTANTS.CONFIG_FILE, CONSTANTS.DATA_DIR)}...")
# create the .archivebox_id file with a unique ID for this collection
from archivebox.config.paths import _get_collection_id
_get_collection_id(CONSTANTS.DATA_DIR, force_create=True)
# create the ArchiveBox.conf file
write_config_file({"SECRET_KEY": config.SECRET_KEY})
if existing_index:
print("\n[green][*] Verifying main SQL index and running any migrations needed...[/green]")
else:
print("\n[green][+] Building main SQL index and running initial migrations...[/green]")
# For postgres, make sure the server is reachable and create the database
# if it doesn't exist yet (sqlite creates its file automatically).
ensure_database_ready()
from archivebox.config.django import setup_django
setup_django()
previous_wants_init = os.environ.get("ARCHIVEBOX_WANTS_INIT")
os.environ["ARCHIVEBOX_WANTS_INIT"] = "1"
try:
check_migrations(blocking=True, auto_apply=False)
for migration_line in apply_migrations(CONSTANTS.DATA_DIR):
sys.stdout.write(f" {migration_line}\n")
finally:
if previous_wants_init is None:
os.environ.pop("ARCHIVEBOX_WANTS_INIT", None)
else:
os.environ["ARCHIVEBOX_WANTS_INIT"] = previous_wants_init
from archivebox.misc.db import database_display_location, is_postgres
assert database_exists()
print()
if is_postgres():
print(f" √ {database_display_location()}")
else:
print(f" √ {_display_data_path(CONSTANTS.DATABASE_FILE, CONSTANTS.DATA_DIR)}")
print()
print("[dodger_blue3][*] Checking links from indexes and archive folders (safe to Ctrl+C)...[/dodger_blue3]")
from archivebox.core.models import Snapshot
snapshot_count = 0
if existing_index:
snapshot_count = Snapshot.objects.count()
print(f" √ Loaded {snapshot_count} links from existing main index.")
print(" > Skipping orphan snapshot import during init.")
print()
print(" [violet]Hint:[/violet] To import orphaned snapshot directories and reconcile filesystem state, run:")
print(" archivebox update")
print("\n[green]----------------------------------------------------------------------[/green]")
from django.contrib.auth.models import User
config = get_config()
if (config.ADMIN_USERNAME and config.ADMIN_PASSWORD) and not User.objects.filter(
username=config.ADMIN_USERNAME,
).exists():
print("[green][+] Found ADMIN_USERNAME and ADMIN_PASSWORD configuration options, creating new admin user.[/green]")
User.objects.create_superuser(username=config.ADMIN_USERNAME, password=config.ADMIN_PASSWORD)
if existing_index:
print("[green][√] Done. Verified and updated the existing ArchiveBox collection.[/green]")
else:
print(f"[green][√] Done. A new ArchiveBox collection was initialized ({snapshot_count} links).[/green]")
CONSTANTS.PERSONAS_DIR.mkdir(parents=True, exist_ok=True)
CONSTANTS.DEFAULT_TMP_DIR.mkdir(parents=True, exist_ok=True)
from archivebox.config.paths import create_and_chown_dir, get_or_create_working_tmp_dir, get_or_create_working_lib_dir
config = get_config()
create_and_chown_dir(config.TMP_DIR)
create_and_chown_dir(config.ABXPKG_LIB_DIR)
create_and_chown_dir(config.ABXPKG_LIB_DIR / "bin")
working_tmp_dir = get_or_create_working_tmp_dir(autofix=True, quiet=True)
if working_tmp_dir:
working_tmp_dir.mkdir(parents=True, exist_ok=True)
working_lib_dir = get_or_create_working_lib_dir(autofix=True, quiet=True)
if working_lib_dir:
working_lib_dir.mkdir(parents=True, exist_ok=True)
if install:
from archivebox.cli.archivebox_install import install as install_method
install_method()
if Snapshot.objects.count() < 25: # hide the hints for experienced users
from archivebox.core.routes_util import build_admin_url
admin_url = build_admin_url("/admin/", config=config)
print()
print(" [violet]Hint:[/violet] To finish web setup, run:")
print(
f" archivebox server # then visit [deep_sky_blue4][link={admin_url}]{admin_url}[/link][/deep_sky_blue4]",
)
print()
print(" To add new links, you can run:")
print(" archivebox add < ~/some/path/to/list_of_links.txt")
print()
print(" For more usage and examples, run:")
print(" archivebox help")
@click.command()
@click.option("--force", "-f", is_flag=True, help="Ignore unrecognized files in current directory and initialize anyway")
@click.option("--quick", "-q", is_flag=True, help="Run any updates or migrations without rechecking all snapshot dirs")
@click.option("--install", "--setup", "-s", is_flag=True, help="Automatically install dependencies and extras used for archiving")
@docstring(init.__doc__)
def main(**kwargs) -> None:
init(**kwargs)
def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional[str]=None) -> None:
parser = argparse.ArgumentParser(
prog=__command__,
description=init.__doc__,
add_help=True,
formatter_class=SmartFormatter,
)
parser.add_argument(
'--force', # '-f',
action='store_true',
help='Ignore unrecognized files in current directory and initialize anyway',
)
parser.add_argument(
'--quick', '-q',
action='store_true',
help='Run any updates or migrations without rechecking all snapshot dirs',
)
parser.add_argument(
'--install', #'-s',
action='store_true',
help='Automatically install dependencies and extras used for archiving',
)
parser.add_argument(
'--setup', #'-s',
action='store_true',
help='DEPRECATED: equivalent to --install',
)
command = parser.parse_args(args or ())
reject_stdin(__command__, stdin)
init(
force=command.force,
quick=command.quick,
install=command.install or command.setup,
out_dir=pwd or DATA_DIR,
)
if __name__ == "__main__":
main()
if __name__ == '__main__':
main(args=sys.argv[1:], stdin=sys.stdin)
+46 -219
View File
@@ -1,229 +1,56 @@
#!/usr/bin/env python3
__package__ = "archivebox.cli"
__package__ = 'archivebox.cli'
__command__ = 'archivebox install'
import os
import shutil
import re
import sys
import argparse
from pathlib import Path
from typing import Optional, List, IO
import rich_click as click
from rich import print
from archivebox.misc.util import docstring, enforce_types
from archivebox.misc.util import docstring
from archivebox.config import DATA_DIR
from ..logging_util import SmartFormatter, reject_stdin
from ..main import install
def _resolve_install_targets(
requested_names: tuple[str, ...],
) -> tuple[list[str], list[str]]:
"""Resolve plugin names and declared binary aliases, leaving unknown tokens raw."""
from archivebox.plugins.discovery import get_plugin_catalog
catalog = get_plugin_catalog()
plugin_names_by_lower = {plugin_name.lower(): plugin_name for plugin_name in catalog}
plugin_names_by_binary_alias: dict[str, set[str]] = {}
for plugin_name, plugin in catalog.items():
for required_binary in plugin.config.required_binaries:
aliases = {required_binary.name.lower()}
template_match = re.fullmatch(r"\{([A-Za-z_][A-Za-z0-9_]*)\}", required_binary.name)
if template_match:
config_key = template_match.group(1)
aliases.add(config_key.lower())
aliases.add(config_key.removesuffix("_BINARY").lower())
default = plugin.config.properties.get(config_key, {}).get("default")
if default:
aliases.add(str(default).lower())
for alias in aliases:
plugin_names_by_binary_alias.setdefault(alias, set()).add(plugin_name)
requested_plugins: list[str] = []
raw_binary_names: list[str] = []
for name in requested_names:
normalized_name = name.lower()
if normalized_name in plugin_names_by_lower:
requested_plugins.append(plugin_names_by_lower[normalized_name])
elif normalized_name in plugin_names_by_binary_alias:
requested_plugins.extend(sorted(plugin_names_by_binary_alias[normalized_name]))
else:
raw_binary_names.append(name)
selected_plugins = catalog.select(requested_plugins) if requested_plugins else catalog.select([])
selected_plugin_names = {name.lower() for name in selected_plugins}
raw_binary_names = [name for name in raw_binary_names if name.lower() not in selected_plugin_names]
return sorted(selected_plugins), sorted(set(raw_binary_names))
def _install_raw_binary_names(binary_names: list[str], binproviders: str) -> None:
"""Install user-requested standalone binaries through the Binary lifecycle."""
from django.utils import timezone
from archivebox.machine.models import Binary, Machine, _canonical_binary_name
from archivebox.services.runner import run_due_binary
machine = Machine.current()
for requested_name in binary_names:
binary_name = _canonical_binary_name(requested_name)
if not binary_name:
raise click.UsageError(f"Invalid binary name: {requested_name}")
binary = Binary.objects.filter(machine=machine, name=binary_name).order_by("-modified_at").first()
if binary is None:
binary = Binary.objects.create(
machine=machine,
name=binary_name,
binproviders=binproviders,
overrides={},
status=Binary.StatusChoices.QUEUED,
)
elif not binary.is_valid or binary.binproviders != binproviders:
binary.binproviders = binproviders
binary.overrides = {}
binary.status = Binary.StatusChoices.QUEUED
binary.retry_at = timezone.now()
binary.save(update_fields=["binproviders", "overrides", "status", "retry_at", "modified_at"])
run_due_binary(binary, lock_seconds=60)
def ensure_data_dir_lib_symlink(data_dir: Path, abxpkg_lib_dir: Path) -> Path | None:
"""Expose the shared abxpkg lib dir at DATA_DIR/lib after install."""
link_path = data_dir / "lib"
target_config_path = abxpkg_lib_dir.expanduser().absolute()
target_path = abxpkg_lib_dir.expanduser().resolve()
from archivebox.config.paths import get_machine_type
scoped_data_lib_path = (link_path / get_machine_type()).absolute()
if target_config_path == scoped_data_lib_path:
if link_path.is_symlink() or link_path.is_file():
link_path.unlink()
target_config_path.mkdir(parents=True, exist_ok=True)
return None
if link_path.resolve(strict=False) == target_path:
return None
target_path.mkdir(parents=True, exist_ok=True)
if link_path.is_symlink():
if link_path.resolve(strict=False) == target_path:
return link_path
link_path.unlink()
elif link_path.exists():
shutil.rmtree(link_path)
relative_target = os.path.relpath(target_path, start=link_path.parent)
link_path.symlink_to(relative_target, target_is_directory=True)
return link_path
@enforce_types
def install(binaries: tuple[str, ...] = (), binproviders: str = "*", dry_run: bool = False) -> None:
"""Detect and install ArchiveBox dependencies by running the abx-dl install flow
Examples:
archivebox install # Install all dependencies
archivebox install wget curl # Install only wget and curl
archivebox install --binproviders=pip yt-dlp # Install yt-dlp using only pip
archivebox install --binproviders=brew,apt # Install all deps using only brew or apt
"""
from archivebox.config.permissions import IS_ROOT, ARCHIVEBOX_USER, ARCHIVEBOX_GROUP
from archivebox.config import CONSTANTS
from archivebox.misc.logging import stderr
from archivebox.cli.archivebox_init import init
archive_dir = CONSTANTS.ARCHIVE_DIR
if dry_run:
print("[dim]Dry run - would detect ArchiveBox dependencies and run the abx-dl install flow[/dim]")
return
if not (os.access(archive_dir, os.R_OK) and archive_dir.is_dir()):
init() # must init full index because we need a db to store Binary entries in
# Show what we're installing
if binaries:
print(f"\n[green][+] Installing specific binaries: {', '.join(binaries)}[/green]")
else:
print("\n[green][+] Detecting and installing all ArchiveBox dependencies...[/green]")
if binproviders != "*":
print(f"[green][+] Using providers: {binproviders}[/green]")
if IS_ROOT:
EUID = os.geteuid()
print()
print(f"[yellow]:warning: Running as UID=[blue]{EUID}[/blue].[/yellow]")
print(f" DATA_DIR will be owned by [blue]{ARCHIVEBOX_USER}:{ARCHIVEBOX_GROUP}[/blue].")
print()
# Set up Django
from archivebox.config.django import setup_django
setup_django()
plugin_names, raw_binary_names = _resolve_install_targets(binaries) if binaries else ([], [])
install_plugin_names = list(plugin_names)
if binproviders != "*":
install_plugin_names.extend(provider.strip() for provider in binproviders.split(",") if provider.strip())
if install_plugin_names or not binaries:
if not binaries:
from archivebox.workers.supervisord_util import require_dependency_install_memory
require_dependency_install_memory()
print("[+] Running plugin installer via abx-dl bus...")
print()
from archivebox.services.runner import run_install
run_install(plugin_names=install_plugin_names or None)
if raw_binary_names:
print(f"[+] Running direct binary installer via ArchiveBox binary lifecycle: {', '.join(raw_binary_names)}")
print()
_install_raw_binary_names(raw_binary_names, binproviders)
from archivebox.config.common import get_config
config = get_config(include_machine=False)
ensure_data_dir_lib_symlink(CONSTANTS.DATA_DIR, config.ABXPKG_LIB_DIR)
print()
# Check for superuser
from django.contrib.auth import get_user_model
User = get_user_model()
if not User.objects.filter(is_superuser=True).exclude(username="system").exists():
from archivebox.core.routes_util import build_admin_url
stderr("\n[+] Open the Admin UI to create the first admin and finish web setup:", color="green")
stderr(f" {build_admin_url('/admin/', config=get_config(resolve_plugins=False))}")
print()
# Show version to display full status including installed binaries
# Django is already loaded, so just import and call the function directly
from archivebox.cli.archivebox_version import version as show_version
show_version(quiet=False, binaries=binaries)
@click.command()
@click.argument("binaries", nargs=-1, type=str, required=False)
@click.option(
"--binproviders",
"-p",
default="*",
help="Comma-separated list of providers to use (pip,npm,brew,apt,env,custom) or * for all",
show_default=True,
)
@click.option("--dry-run", "-d", is_flag=True, help="Show what would happen without actually running", default=False)
@docstring(install.__doc__)
def main(**kwargs) -> None:
install(**kwargs)
def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional[str]=None) -> None:
parser = argparse.ArgumentParser(
prog=__command__,
description=install.__doc__,
add_help=True,
formatter_class=SmartFormatter,
)
parser.add_argument(
'--binproviders', '-p',
type=str,
help='Select binproviders to use DEFAULT=env,apt,brew,sys_pip,venv_pip,lib_pip,pipx,sys_npm,lib_npm,puppeteer,playwright (all)',
default=None,
)
parser.add_argument(
'--binaries', '-b',
type=str,
help='Select binaries to install DEFAULT=curl,wget,git,yt-dlp,chrome,single-file,readability-extractor,postlight-parser,... (all)',
default=None,
)
parser.add_argument(
'--dry-run', '-d',
action='store_true',
help='Show what would be installed without actually installing anything',
default=False,
)
command = parser.parse_args(args or ()) # noqa
reject_stdin(__command__, stdin)
install(
# force=command.force,
out_dir=Path(pwd) if pwd else DATA_DIR,
binaries=command.binaries.split(',') if command.binaries else None,
binproviders=command.binproviders.split(',') if command.binproviders else None,
dry_run=command.dry_run,
)
if __name__ == "__main__":
main()
if __name__ == '__main__':
main(args=sys.argv[1:], stdin=sys.stdin)
+130 -13
View File
@@ -1,22 +1,139 @@
#!/usr/bin/env python3
__package__ = "archivebox.cli"
__command__ = "archivebox list"
__package__ = 'archivebox.cli'
__command__ = 'archivebox list'
import sys
import argparse
from pathlib import Path
from typing import Optional, List, IO
import rich_click as click
from archivebox.cli.archivebox_snapshot import list_snapshots, snapshot_filter_options, snapshot_output_options
from archivebox.misc.util import docstring
from archivebox.config import DATA_DIR
from ..main import list_all
from ..index import (
LINK_FILTERS,
get_indexed_folders,
get_archived_folders,
get_unarchived_folders,
get_present_folders,
get_valid_folders,
get_invalid_folders,
get_duplicate_folders,
get_orphaned_folders,
get_corrupted_folders,
get_unrecognized_folders,
)
from ..logging_util import SmartFormatter, reject_stdin, stderr
@click.command()
@snapshot_output_options
@snapshot_filter_options(default_filter_type="substring")
def main(**kwargs) -> None:
"""List Snapshots."""
sys.exit(list_snapshots(**kwargs))
@docstring(list_all.__doc__)
def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional[str]=None) -> None:
parser = argparse.ArgumentParser(
prog=__command__,
description=list_all.__doc__,
add_help=True,
formatter_class=SmartFormatter,
)
group = parser.add_mutually_exclusive_group()
group.add_argument(
'--csv', #'-c',
type=str,
help="Print the output in CSV format with the given columns, e.g.: timestamp,url,extension",
default=None,
)
group.add_argument(
'--json', #'-j',
action='store_true',
help="Print the output in JSON format with all columns included",
)
group.add_argument(
'--html',
action='store_true',
help="Print the output in HTML format"
)
parser.add_argument(
'--with-headers',
action='store_true',
help='Include the headers in the output document'
)
parser.add_argument(
'--sort', #'-s',
type=str,
help="List the links sorted using the given key, e.g. timestamp or updated",
default=None,
)
parser.add_argument(
'--before', #'-b',
type=float,
help="List only links bookmarked before (less than) the given timestamp",
default=None,
)
parser.add_argument(
'--after', #'-a',
type=float,
help="List only links bookmarked after (greater than or equal to) the given timestamp",
default=None,
)
parser.add_argument(
'--status',
type=str,
choices=('indexed', 'archived', 'unarchived', 'present', 'valid', 'invalid', 'duplicate', 'orphaned', 'corrupted', 'unrecognized'),
default='indexed',
help=(
'List only links or data directories that have the given status\n'
f' indexed {get_indexed_folders.__doc__} (the default)\n'
f' archived {get_archived_folders.__doc__}\n'
f' unarchived {get_unarchived_folders.__doc__}\n'
'\n'
f' present {get_present_folders.__doc__}\n'
f' valid {get_valid_folders.__doc__}\n'
f' invalid {get_invalid_folders.__doc__}\n'
'\n'
f' duplicate {get_duplicate_folders.__doc__}\n'
f' orphaned {get_orphaned_folders.__doc__}\n'
f' corrupted {get_corrupted_folders.__doc__}\n'
f' unrecognized {get_unrecognized_folders.__doc__}\n'
)
)
parser.add_argument(
'--filter-type', '-t',
type=str,
choices=(*LINK_FILTERS.keys(), 'search'),
default='exact',
help='Type of pattern matching to use when filtering URLs',
)
parser.add_argument(
'filter_patterns',
nargs='*',
type=str,
default=None,
help='List only URLs matching these filter patterns'
)
command = parser.parse_args(args or ())
reject_stdin(stdin)
if command.with_headers and not (command.json or command.html or command.csv):
stderr(
'[X] --with-headers can only be used with --json, --html or --csv options\n',
color='red',
)
raise SystemExit(2)
if __name__ == "__main__":
main()
matching_folders = list_all(
filter_patterns=command.filter_patterns,
filter_type=command.filter_type,
status=command.status,
after=command.after,
before=command.before,
sort=command.sort,
csv=command.csv,
json=command.json,
html=command.html,
with_headers=command.with_headers,
out_dir=Path(pwd) if pwd else DATA_DIR,
)
raise SystemExit(not matching_folders)
if __name__ == '__main__':
main(args=sys.argv[1:], stdin=sys.stdin)
-102
View File
@@ -1,102 +0,0 @@
#!/usr/bin/env python3
"""
archivebox machine <action> [--filters]
Manage Machine records (system-managed, mostly read-only).
Machine records track the host machines where ArchiveBox runs.
They are created automatically by the system and are primarily for debugging.
Actions:
list - List Machines as JSONL (with optional filters)
Examples:
# List all machines
archivebox machine list
# List machines by hostname
archivebox machine list --hostname__icontains=myserver
"""
__package__ = "archivebox.cli"
__command__ = "archivebox machine"
import sys
import rich_click as click
from rich import print as rprint
from archivebox.cli.cli_util import apply_filters
# =============================================================================
# LIST
# =============================================================================
def list_machines(
hostname__icontains: str | None = None,
os_platform: str | None = None,
limit: int | None = None,
) -> int:
"""
List Machines as JSONL with optional filters.
Exit codes:
0: Success (even if no results)
"""
from archivebox.misc.jsonl import write_record
from archivebox.machine.models import Machine
is_tty = sys.stdout.isatty()
queryset = Machine.objects.all().order_by("-created_at")
# Apply filters
filter_kwargs = {
"hostname__icontains": hostname__icontains,
"os_platform": os_platform,
}
queryset = apply_filters(queryset, filter_kwargs, limit=limit)
count = 0
for machine in queryset:
if is_tty:
rprint(f"[cyan]{machine.hostname:30}[/cyan] [dim]{machine.os_platform:10}[/dim] {machine.id}")
else:
write_record(machine.to_json())
count += 1
rprint(f"[dim]Listed {count} machines[/dim]", file=sys.stderr)
return 0
# =============================================================================
# CLI Commands
# =============================================================================
@click.group()
def main():
"""Manage Machine records (read-only, system-managed)."""
pass
@main.command("list")
@click.option("--hostname__icontains", help="Filter by hostname contains")
@click.option("--os-platform", help="Filter by OS platform")
@click.option("--limit", "-n", type=int, help="Limit number of results")
def list_cmd(hostname__icontains: str | None, os_platform: str | None, limit: int | None):
"""List Machines as JSONL."""
sys.exit(
list_machines(
hostname__icontains=hostname__icontains,
os_platform=os_platform,
limit=limit,
),
)
if __name__ == "__main__":
main()
+16 -27
View File
@@ -1,35 +1,24 @@
#!/usr/bin/env python3
__package__ = "archivebox.cli"
__package__ = 'archivebox.cli'
__command__ = 'archivebox manage'
import rich_click as click
from archivebox.misc.util import docstring, enforce_types
import sys
from pathlib import Path
from typing import Optional, List, IO
from archivebox.misc.util import docstring
from archivebox.config import DATA_DIR
from ..main import manage
@enforce_types
def manage(args: list[str] | None = None) -> None:
"""Run an ArchiveBox Django management command"""
from archivebox.config.common import get_config
from archivebox.misc.logging import stderr
config = get_config()
if (args and "createsuperuser" in args and "--noinput" not in args) and (config.IN_DOCKER and not config.IS_TTY):
stderr("[!] Warning: you need to pass -it to use interactive commands in docker", color="lightyellow")
stderr(" docker run -it archivebox manage {}".format(" ".join(args or ["..."])), color="lightyellow")
stderr("")
from django.core.management import execute_from_command_line
execute_from_command_line(["manage.py", *(args or ["help"])])
@click.command(add_help_option=False, context_settings=dict(ignore_unknown_options=True))
@click.argument("args", nargs=-1)
@docstring(manage.__doc__)
def main(args: list[str] | None = None) -> None:
manage(args=args)
def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional[str]=None) -> None:
manage(
args=args,
out_dir=Path(pwd) if pwd else DATA_DIR,
)
if __name__ == "__main__":
main()
if __name__ == '__main__':
main(args=sys.argv[1:], stdin=sys.stdin)
-49
View File
@@ -1,49 +0,0 @@
#!/usr/bin/env python3
"""
archivebox mcp
Start the Model Context Protocol (MCP) server in stdio mode.
Exposes all ArchiveBox CLI commands as MCP tools for AI agents.
"""
__package__ = "archivebox.cli"
__command__ = "archivebox mcp"
import rich_click as click
from archivebox.misc.util import docstring, enforce_types
@enforce_types
def mcp():
"""
Start the MCP server in stdio mode for AI agent control.
The MCP (Model Context Protocol) server exposes all ArchiveBox CLI commands
as tools that AI agents can discover and execute. It communicates via JSON-RPC
2.0 over stdin/stdout.
Example usage with an MCP client:
archivebox mcp < requests.jsonl > responses.jsonl
Or interactively:
archivebox mcp
{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}
{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}
"""
from archivebox.mcp.server import run_mcp_server
# Run the stdio server (blocks until stdin closes)
run_mcp_server()
@click.command()
@docstring(mcp.__doc__)
def main(**kwargs):
"""Start the MCP server in stdio mode"""
mcp()
if __name__ == "__main__":
main()
+61 -37
View File
@@ -1,49 +1,73 @@
#!/usr/bin/env python3
__package__ = "archivebox.cli"
__command__ = "archivebox oneshot"
__package__ = 'archivebox.cli'
__command__ = 'archivebox oneshot'
import subprocess
import sys
import argparse
from pathlib import Path
from typing import List, Optional, IO
import rich_click as click
from archivebox.config import CONSTANTS
from archivebox.config.common import get_config
from archivebox.misc.util import docstring
from archivebox.config import DATA_DIR
from ..logging_util import SmartFormatter, accept_stdin, stderr
from ..main import oneshot
@click.command(add_help_option=False, context_settings=dict(ignore_unknown_options=True))
@click.argument("args", nargs=-1)
def main(args: tuple[str, ...] = ()) -> None:
"""Download URLs using abx-dl"""
from archivebox.misc.checks import check_not_inside_source_dir
check_not_inside_source_dir()
cwd = Path.cwd()
if any((path / CONSTANTS.SQL_INDEX_FILENAME).exists() for path in (cwd, *cwd.parents)):
raise click.ClickException(
"Refusing to run `archivebox oneshot` inside an ArchiveBox DATA_DIR. Use `archivebox add` here, or run oneshot from another directory.",
@docstring(oneshot.__doc__)
def main(args: Optional[List[str]]=None, stdin: Optional[IO]=None, pwd: Optional[str]=None) -> None:
parser = argparse.ArgumentParser(
prog=__command__,
description=oneshot.__doc__,
add_help=True,
formatter_class=SmartFormatter,
)
parser.add_argument(
'url',
type=str,
default=None,
help=(
'URLs or paths to archive e.g.:\n'
' https://getpocket.com/users/USERNAME/feed/all\n'
' https://example.com/some/rss/feed.xml\n'
' https://example.com\n'
' ~/Downloads/firefox_bookmarks_export.html\n'
' ~/Desktop/sites_list.csv\n'
)
abxpkg_binary = Path(sys.executable).with_name("abxpkg")
if not abxpkg_binary.is_file():
raise click.ClickException(f"abxpkg executable is missing from the ArchiveBox environment: {abxpkg_binary}")
abxpkg_lib_dir = get_config(include_machine=False).ABXPKG_LIB_DIR
raise SystemExit(
subprocess.run(
[
str(abxpkg_binary),
f"--lib={abxpkg_lib_dir}",
"--binproviders=env",
"--install",
"run",
"abx-dl",
*args,
],
).returncode,
)
parser.add_argument(
"--extract",
type=str,
help="Pass a list of the extractors to be used. If the method name is not correct, it will be ignored. \
This does not take precedence over the configuration",
default=""
)
parser.add_argument(
'--out-dir',
type=str,
default=DATA_DIR,
help= "Path to save the single archive folder to, e.g. ./example.com_archive"
)
command = parser.parse_args(args or ())
stdin_url = None
url = command.url
if not url:
stdin_url = accept_stdin(stdin)
if (stdin_url and url) or (not stdin and not url):
stderr(
'[X] You must pass a URL/path to add via stdin or CLI arguments.\n',
color='red',
)
raise SystemExit(2)
oneshot(
url=stdin_url or url,
out_dir=Path(command.out_dir).resolve(),
extractors=command.extract,
)
if __name__ == "__main__":
main()
if __name__ == '__main__':
main(args=sys.argv[1:], stdin=sys.stdin)
Loaded 100 of 1108 files, more files were not shown because too many files have changed in this diff. Show more