aswan
Parallel web data collection with structured, versioned storage. Named after the Aswan Dam.
Collect data from the internet quickly and in parallel, store it compressed and content-addressed, and replay or parse it later — without ever losing a run.
Install
uv add aswan
# HTML parsing (the Soup handlers) and the monitoring dashboard
uv add "aswan[soup,monitor]"
Optional extras: soup (BeautifulSoup, needed by every *SoupHandler), browser (Selenium), relay (external-fetch bridge), monitor (Dash dashboard), legacy (PyYAML, for aswan migrate), test. Remote sync uses the system rsync binary and needs no extra.
Quick Start
Needs the soup extra: without BeautifulSoup every URL of a Soup handler ends in Statuses.PARSING_ERROR.
from aswan import Project, RequestSoupHandler
class ArticleHandler(RequestSoupHandler):
url_root = "https://news.ycombinator.com"
def parse(self, soup):
for link in soup.select("a.titlelink"):
self.register_links_to_handler([link["href"]])
return {"title": soup.title.text}
project = Project("hn-scrape")
project.run(urls_to_overwrite={ArticleHandler: ["https://news.ycombinator.com"]})
project.commit_current_run()
# Read collected data
for event in project.depot.get_handler_events(ArticleHandler):
print(event.url, event.content)
Concepts
Handlers
Handlers define how to fetch and parse a URL. Subclass one:
| Handler | Use case |
|---|---|
RequestSoupHandler |
HTTP + BeautifulSoup HTML parsing |
RequestJsonHandler |
HTTP + JSON parsing |
RequestHandler |
HTTP, raw bytes |
BrowserSoupHandler |
Chrome via Selenium + HTML parsing |
BrowserJsonHandler |
Chrome via Selenium + JSON |
BrowserHandler |
Chrome via Selenium, raw page source |
Browser handlers open a visible Chrome window unless the handler sets headless = True; a visible browser occupies one of the project's max_displays, so set headless = True on a machine without a display. eager = True switches Chrome to its eager page-load strategy. A proxy that needs authentication only works with a visible browser.
Override parse(self, content) to extract and return data. Return anything JSON-serializable, or raw bytes/str.
Call self.register_links_to_handler(urls) inside parse() to enqueue new URLs for collection.
class ProductHandler(RequestSoupHandler):
url_root = "https://shop.example.com"
max_in_parallel = 4 # limit concurrency for this handler
def parse(self, soup):
# enqueue pagination links
if next_page := soup.select_one("a.next"):
self.register_links_to_handler([next_page["href"]])
return {
"name": soup.select_one("h1").text,
"price": soup.select_one(".price").text,
}
Project
Project orchestrates collection runs and manages the data depot.
project = Project(
name="my-project", # depot name; determines storage directory
local_root=None, # depot storage root; else $ASWAN_DEPOT_ROOT, else DEFAULT_DEPOT_ROOT
max_cpu_use=4.0, # number of CPU cores to use
max_displays=1, # visible (non-headless) browsers allowed at once
stall_timeout=None, # abort the run if the scheduler makes no progress for this many seconds
task_timeout=None, # fail a single URL whose handling takes longer than this
debug=False,
)
DEFAULT_DEPOT_ROOT and the other defaults live in aswan.constants.
Running a collection:
# Add URLs only if not already in the depot
project.run(urls_to_register={ProductHandler: ["https://shop.example.com/category/1"]})
# Force-add URLs, overwriting prior status
project.run(urls_to_overwrite={ProductHandler: ["https://shop.example.com/sale"]})
# Test run: collects each handler's test_urls; it can't be committed and the next run() discards it
project.run(test_run=True)
# Commit and archive the completed run
project.commit_current_run()
Registration & deduplication:
A URL already in the depot's history is not fetched again when registered again: urls_to_register (and register_links_to_handler) only enqueue URLs the depot has never seen. That holds for failed URLs too, so a dead link that many pages point to is fetched once, not once per page; retry failures explicitly with continue_run (below). To deliberately re-collect a URL, register it with overwrite: urls_to_overwrite= or register_links_to_handler(..., overwrite=True), which resets it to todo. URLs whose session broke (Statuses.SESSION_BROKEN) are retried automatically.
Resuming and retrying:
If a run was interrupted, URLs may be stuck in processing; failed URLs stay in their error state until retried. Resume with:
project.continue_run(
inprogress=True, # retry URLs stuck in processing
conn_error=True, # retry connection errors
parsing_error=False, # skip parsing errors (default)
)
Data Depot
The depot stores all collected data as immutable, versioned runs. Each commit_current_run() archives the run and updates the status index.
Directory layout:
<depot root>/my-project/
├── object-store/ # content-addressed blob storage
│ ├── staging/ # objects of the uncommitted run
│ └── ark/ # committed objects (dirark: index.sqlite + tar.zst)
├── runs/ # archived collection runs
│ └── <run-name>/
│ ├── context.json # git commit, environment snapshot
│ └── events.zip # serialized collection events
├── status.sqlite.zip # materialized source-URL status (zipped sqlite)
├── status-runs.json # run ids already folded into status.sqlite.zip
└── current-run/ # active run (cleared on commit)
├── context.json
├── events/
└── db.sqlite
Reading collected data:
depot = project.depot
for event in depot.get_handler_events():
print(event.url)
print(event.handler_name)
data = event.content # whatever parse() returned
Filtering events:
get_handler_events selects and filters; each yielded ParsedCollectionEvent exposes .url, .handler_name, .status, and .content. Pass from_current=True to read the uncommitted (or test) run instead of the committed history.
# successful events for one handler, latest per URL (the defaults)
for event in depot.get_handler_events(ProductHandler):
data = event.content
# include failures, keep every occurrence, restrict to the last 2 runs
for event in depot.get_handler_events(
ProductHandler, only_successful=False, only_latest=False, past_runs=2
):
print(event.status, event.url)
Remote Sync
Sync a depot with a remote copy for distributed collection or backup. Push/pull shell out to the system rsync binary (Linux-only by design), so any target rsync understands works — a local path or user@host:/path; the depot lives at <remote>/<depot name>.
Both directions only add what the other side lacks. Runs never change, and the status files are a cache that is repaired by replaying the runs it is missing, so both transfer without overwriting. Committed objects sync through dirark's index-first merge: only objects the other side lacks move, and objects pushed from different machines all survive. current-run/ and objects of the uncommitted run stay local. Pulling a depot the remote does not hold yet is a no-op. Two machines must not push to the same remote at the same moment.
Set a default target with ASWAN_REMOTE, or pass it explicitly:
export ASWAN_REMOTE=user@host:/srv/aswan-depots
project.depot.pull() # pull from $ASWAN_REMOTE before running
project.run(...)
project.commit_current_run()
project.depot.push() # push results back
project.depot.push("user@host:/srv/aswan-depots") # or target explicitly
To merge depots collected independently (e.g. one per machine) without a shared remote, use target.merge(other).
Convenience Functions
For one-off fetching without setting up a project:
from aswan import get_soup, get_json
soup = get_soup("https://example.com")
data = get_json("https://api.example.com/endpoint")
# With browser
soup = get_soup("https://example.com", browser=True, headless=True)
Handler Reference
Class attributes
| Attribute | Type | Description |
|---|---|---|
url_root |
str | None |
Base URL for resolving relative links |
test_urls |
list[str] |
URLs used during test_run=True |
proxy_cls |
type[ProxyBase] |
Proxy rotation for this handler (see Proxies) |
max_in_parallel |
int | None |
Max concurrent requests for this handler |
max_retries |
int |
Attempts per URL, the first one included, before it is marked failed |
restart_session_after |
int |
Restart the session after this many requests |
headless |
bool |
Browser handlers: run Chrome without a window |
eager |
bool |
Browser handlers: eager page-load strategy |
Methods to override
| Method | Signature | Description |
|---|---|---|
parse(content) |
(soup/dict/bytes) -> Any |
Extract and return data from fetched content |
handle_driver(session) |
(Session or Chrome) -> Any |
Request handlers: runs before each request, e.g. to set headers or cookies. Browser handlers: runs after the page loads; a non-None return replaces the page source as the content passed to parse |
start_session(session) |
(Session or Chrome) -> None |
One-time session initialization |
is_session_broken(result) |
(int or Exception) -> bool |
Whether a failure warrants restarting the session |
load_cache(url) |
(str) -> Any | None |
Return pre-collected data to skip the fetch (and parse); status becomes CACHE_LOADED. Useful for offline replay or seeding a depot with results gathered elsewhere |
Registering URLs from within parse()
def parse(self, soup):
# register to same handler
self.register_links_to_handler(["/page/2", "/page/3"])
# register to a different handler
self.register_links_to_handler(
["/item/42"], handler_cls=DetailHandler
)
# register with overwrite (re-collect even if already done)
self.register_links_to_handler(["/dynamic"], overwrite=True)
# register URL with query params
self.register_url_with_params({"page": 2, "limit": 50})
return soup.get_text()
Status Values
| Constant | Value | Meaning |
|---|---|---|
Statuses.TODO |
todo |
Pending collection |
Statuses.PROCESSING |
processing |
Currently being collected |
Statuses.PROCESSED |
D |
Successfully collected |
Statuses.PARSING_ERROR |
PE |
parse() raised an exception |
Statuses.CONNECTION_ERROR |
CE |
Network or HTTP failure |
Statuses.SESSION_BROKEN |
SB |
Session needs restart |
Statuses.CACHE_LOADED |
CL |
Returned by load_cache() |
Monitoring
With the monitor extra, a Dash dashboard shows the current run of a depot: collection rates, handler status breakdowns, and recent events.
uv run aswan monitor my-project # --root DIR, --port N, --interval SECS, --debug
Migrating a pre-1.0 depot
A depot written before 1.0 keeps one zip file per object and YAML contexts, which the current object store cannot read. aswan migrate copies its runs into a current depot, re-storing every object the events name and rewriting their names:
uv run --extra legacy aswan migrate ~/old-depots/my-project ~/aswan-depots/my-project
The target is created if missing. Runs it already holds are skipped, so the same command resumes an interrupted migration, pulls new runs from a still-legacy live depot, and merges several copies of one depot into one target. The events of the legacy current-run become a run of their own. A run with a missing or unreadable object is not written: the summary lists it, and the command exits non-zero.
Proxies
from aswan import ProxyAuth, ProxyBase
class MyProxy(ProxyBase):
port_no = 8080
def _load_host_list(self) -> list[str]:
return ["proxy1.example.com", "proxy2.example.com"] # rotated per session
def get_creds(self) -> ProxyAuth | None:
return ProxyAuth(user="u", password="p") # omit/return None for no auth
class MyHandler(RequestSoupHandler):
proxy_cls = MyProxy
Time Constants
from aswan import ONE_MINUTE, ONE_HOUR, ONE_DAY, ONE_WEEK, ONE_YEAR
These are integer values in seconds, useful for scheduling and TTL logic.
Metadata
Release files for aswan 1.0.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| aswan-1.0.1.tar.gz | 50.5 kB | Without provenance | Details for aswan-1.0.1.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| aswan-1.0.1-py3-none-any.whl | Python 3 | none | any | Details for aswan-1.0.1-py3-none-any.whl |
Total release size: 113.0 kB
Release files / aswan-1.0.1.tar.gz
| Download URL | aswan-1.0.1.tar.gz |
|---|---|
| Size | 50.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
341ba368119784fced1a11c7c2227b1a28784fc34fd2fbec88dac3dc73b94347
|
|
BLAKE2b-256 checksum How to use checksums |
c97ea469484f52a9d0f762a982afe01f8f363266d7dcc73851aa87dc5089ef25
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Release files / aswan-1.0.1-py3-none-any.whl
| Download URL | aswan-1.0.1-py3-none-any.whl |
|---|---|
| Size | 62.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
5240215334126e8a20ad9c87dafcd6a26686fffe07a6e71e6dd1715393c51e70
|
|
BLAKE2b-256 checksum How to use checksums |
340765af5151a8da21dcec45b7b4d383e523d4ffd8a67fda1031acca0b1f25f8
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
uv/0.13.6 {"installer":{"name":"uv","version":"0.13.6","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"26.04","id":"resolute","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}
|
Security for aswan 1.0.1
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
No provenance found
None of the files in this release were uploaded with provenance metadata.
A note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.