Python client for the Low Resource Language Library API.
It does two things:
- Sample (
run): queries/api/collections/queryfor a random sample of up to 100 text, audio, or video files, downloads them (including audio/video transcriptions), and saves a local JSON-LD with rewritten file paths. - Download whole collections (
download_collections/download-all): downloads every file of each matching audio/video collection as a bundle. See Downloading whole collections.
# From the project root (editable / dev install)
pip install -e .The Python reader loads DDPLRLL_ settings from environment variables or .env. The CLI takes its settings as options (see CLI Usage); for its API URL, pass --api-url or export DDPLRLL_API_BASE_URL in your shell.
The reader sends a bearer token that it reads from tokens.json in the project root. You create this file yourself and paste the token into it by hand. It has exactly one field:
{"access_token": "PASTE_THE_RAW_LRLL_API_ACCESS_TOKEN_HERE"}To get the token:
- Sign in to the web app (SPA) in your browser.
- Open the developer tools (Network panel) and select any request to the LRLL API.
- Copy the value of its
Authorizationrequest header, without theBearerprefix. - Paste it as the
access_tokenvalue intokens.jsonand save the file.
Do not use an ID token or a Microsoft Graph token. Tokens expire; when requests start failing with 401 Unauthorized, copy a fresh token and paste it in again. tokens.json is git-ignored, so never commit it or share it.
Pasting the token needs no redirect URI, callback port, or .env authentication settings. Run commands from the project root so the default tokens.json path resolves to the same file. To use a different token for one CLI command, pass --api-token.
For an Entra app registered as a Single-page application, run:
python textWithSpaAuth.py
# Or download a sound sample using the same browser sign-in:
python soundWithSpaAuth.pySet these in .env or the environment (see .env.example):
ENTRA_TENANT_ID=YOUR_TENANT_ID
ENTRA_CLIENT_ID=YOUR_SPA_CLIENT_ID
ENTRA_API_SCOPE=api://YOUR_API_APP_ID/access_as_user
ENTRA_SPA_REDIRECT_URI=http://localhost:5173/callback
ENTRA_SPA_TIMEOUT_SECONDS=300Use the delegated LRLL API scope granted to your app, not a Microsoft Graph
scope. The redirect URI must be registered exactly under the SPA platform. Stop
any development server using port 5173 first; the helper cannot silently use
another unregistered port. Other registered http://localhost:<port> callbacks
are supported, including a root callback such as http://localhost:5071.
Python hosts a temporary loopback-only page and opens your default browser.
The page uses authorization code flow with PKCE, validates OAuth state, and
redeems the code in the real browser. It sends the access token back to
Python through a protected, one-time local handoff. No client secret, browser
impersonation headers, JavaScript build, or additional dependencies are needed.
The browser must be able to reach login.microsoftonline.com, and Entra consent
and tenant policies still apply. Browser authentication always uses normal TLS
validation; the example's API SSL setting does not affect sign-in.
The SPA sign-in scripts sign in on each run and use the token in memory to download
the same samples as their WithSpaToken counterparts. They do not overwrite
tokens.json. The text SPA examples and all sound examples print the user's name
and username from the access token when
available, falling back to a user ID or an explicit unavailable message. These
JWT claims are decoded for display only, not verified or used for authorization;
loading a pasted token does not establish that it is valid or unexpired.
The token itself is never printed.
Keep the sign-in tab open until it reports completion. Closing it early leads
to a timeout; restart the script or use Ctrl+C to cancel. Errors and the default
five-minute timeout are reported in the terminal. Tokens are not automatically
refreshed; sign in again when a fresh token is needed.
For other Python workflows:
from ddplrll_reader import EntraConfig, acquire_api_token_spa, login_entra_spa
result = acquire_api_token_spa(EntraConfig()) # Token in memory only
token = result["access_token"]
# Alternatively, explicitly sign in and save an access token to tokens.json.
# login_entra_spa(EntraConfig())Existing acquire_api_token / login_entra use MSAL Python's desktop flow.
They require a Mobile and desktop applications redirect registration and use
ENTRA_CALLBACK_PORT; that setting does not control the SPA callback. A desktop
flow cannot redeem a code for a SPA-only redirect URI. See Microsoft's
SPA redirect requirements.
kloakAzure.py signs in through Keycloak. Run python kloakAzure.py, log in in the browser window that opens, and the script saves the tokens to tokens.json. It only signs you in; to query and download, run ddplrll-reader or one of the example scripts afterwards. It uses the LowResourceLanguageDataTrust realm at https://lrllkeyclock.azurewebsites.net by default. To use another Keycloak host, realm, or client, edit the CONFIG values at the top of the script.
Run an example script from the project root. The WithSpaToken examples read
tokens.json directly; there is no interactive prompt. textWithSpaAuth.py and
soundWithSpaAuth.py instead sign in through the browser.
| Script | Query |
|---|---|
nsoWithSpaToken.py |
All audio from the provider National Statistics Office, each with its transcript → output/nso/<collection id>/ (see Downloading whole collections) |
soundWithSpaToken.py |
A random sample of 2 audio files in Chichewa (ny) from 2026 → output/sound/ |
soundWithSpaAuth.py |
Browser SPA sign-in, then the same 2-file sound sample → output/sound/ |
textWithSpaToken.py |
A random sample of 10 text files matching malaria → output/text/ |
textWithSpaAuth.py |
Browser SPA sign-in, then the same 10-file text sample → output/text/ |
The sound and text scripts save the files to <output>/files/ and the metadata to <output>/dataset.jsonld. Edit the constants at the top of a script as needed. Set API_BASE_URL to the API host called by the SPA, e.g. https://lrldtmetadataqa.worldbank.org/.
The CLI has three commands:
rundownloads a random sample of up to 100 files of any media type.download-alldownloads every file of each matching audio/video collection.healthchecks that the API is up.
The CLI defaults to http://localhost:5000, so the examples below specify --api-url. You can instead export DDPLRLL_API_BASE_URL=https://lrldtmetadataqa.worldbank.org in your shell.
Options shared by all commands:
| Flag | Default | Description |
|---|---|---|
--api-url, -u |
http://localhost:5000 |
Base URL of the API; can also be set with an exported DDPLRLL_API_BASE_URL |
--api-token, -k |
from tokens.json |
Bearer token for this command only (run and download-all) |
--no-verify-ssl |
off | Skip SSL certificate verification (e.g. behind a proxy) |
run options:
| Flag | Default | Description |
|---|---|---|
--media-type, -m |
— | Text, Audio, or Video |
--provider, -P |
— | Provider name (partial, case-insensitive), e.g. National Statistics Office |
--collection-id |
— | Restrict to a single collection |
--language, -L |
— | Collection language name or code (partial), e.g. nya |
--keyword, -K |
— | File or collection keyword (partial) |
--theme, -t |
— | File or collection theme (partial) |
--author, -a |
— | Text file author (partial) |
--year, -y |
— | Text publication year, or a year inside the collection's coverage |
--limit, -l |
30 |
Max file entries (1–100). The API returns a random sample of this size; to get everything, use download-all |
--output, -o |
./output |
Output directory |
--no-download |
off | Save the JSON-LD only; skip downloading files |
--concurrency, -c |
5 |
Parallel downloads |
--file-timeout |
300 |
Max seconds for a single file download |
--verbose, -v |
off | Debug logging |
download-all options:
| Flag | Default | Description |
|---|---|---|
--media-type, -m |
— | Audio or Video; text collections are always skipped |
--provider, -P |
— | Provider name (partial, case-insensitive) |
--language, -L |
— | Collection language name or code (partial) |
--theme, -t |
— | Collection theme (partial) |
--year, -y |
— | A year inside the collection's coverage |
--output, -o |
./output |
Collections are extracted to <output>/<collection id>/ |
--keep-zip |
off | Keep each collection's ZIP after extracting it |
--verbose, -v |
off | Debug logging |
# First, paste your access token into tokens.json (see "Authentication").
# A random sample of 10 files from one provider
ddplrll-reader run \
--api-url https://lrldtmetadataqa.worldbank.org \
--provider "National Statistics Office" \
--limit 10 \
--output ./nso-output
# Text files
ddplrll-reader run --api-url https://lrldtmetadataqa.worldbank.org --media-type Text --keyword malaria --year 2024
# Audio in Chichewa, metadata only
ddplrll-reader run --api-url https://lrldtmetadataqa.worldbank.org --media-type Audio --language nya --no-download
# Every audio file from one provider, in full (see "Downloading whole collections")
ddplrll-reader download-all --api-url https://lrldtmetadataqa.worldbank.org \
--provider "National Statistics Office" --media-type Audio --output ./nso-output
# Health check
ddplrll-reader health --api-url https://lrldtmetadataqa.worldbank.org
# Optional: override token from tokens.json for one command
ddplrll-reader run --api-url https://lrldtmetadataqa.worldbank.org --api-token MY_TOKEN --keyword educationfrom ddplrll_reader import DdplrllDatasetClient, Settings
# First, paste your access token into tokens.json (see "Authentication").
# Configure
settings = Settings(
api_base_url="https://lrldtmetadataqa.worldbank.org",
output_dir="./output",
)
client = DdplrllDatasetClient(settings)
# Full pipeline: query → download files → save dataset.jsonld
jsonld_path = client.run(provider="National Statistics Office", limit=10)
print(f"Saved to {jsonld_path}")
# Filters: media_type, collection_id, provider, language, keyword, theme, author, year, limit
text_path = client.run(media_type="Text", keyword="malaria", year=2024, output_dir="./output/text")
audio_path = client.run(media_type="Audio", language="nya", limit=20, output_dir="./output/audio")
# Query only (returns raw dict)
data = client.query(media_type="Text", theme="Education")
# Query with Pydantic validation
response = client.query_validated(keyword="malaria")
for dataset in response.graph or []:
print(dataset.sc_name, dataset.ddpv_media_type)
for f in dataset.distribution or []:
print(f" {f.sc_name} → {f.sc_content_url}")
if f.ddpv_transcription:
print(f" transcription → {f.ddpv_transcription.sc_content_url}")run and query use /api/collections/query, which returns a random sample of at most 100 files per call; repeated calls overlap and never guarantee a complete set. To download everything, use download_collections (or ddplrll-reader download-all):
- It lists every matching collection from the paged catalog (
/api/catalog/collections). - For each audio/video collection it downloads the bundle ZIP (
/api/collections/{id}/bundle), which holds every media file followed by its transcription, plus ametadata.jsonldCroissant document. - It extracts the ZIP to
<output>/<collection id>/and deletes it (passkeep_zip=True/--keep-zipto keep it). - It rewrites every
sc:contentUrlinmetadata.jsonld, including those of nestedddpv:transcriptionobjects, from the API URL to the absolute path of the extracted file. Files missing from the bundle keep their API URL.
client = DdplrllDatasetClient(Settings(api_base_url="https://lrldtmetadataqa.worldbank.org", output_dir="./output/nso"))
# Filters: media_type, provider, language, theme, year
folders = client.download_collections(provider="National Statistics Office", media_type="Audio")
# Just list what would be downloaded
for c in client.list_collections(provider="National Statistics Office"):
print(c["id"], c["mediaType"], c["items"], "files")output/nso/
└── MW-NYA-NSO-AUD-001/
├── metadata.jsonld # Croissant JSON-LD for the whole collection, with local file paths
├── <recording>.wav
├── <recording>.txt # its transcription
├── ...
└── MISSING_FILES.txt # only if some files were missing on the server
Notes:
- Text collections are skipped; the API cannot bundle them.
runcan only fetch a random sample of up to 100 text files per call; there is no complete text download in this library yet. - Bundles can be several GB and download as a single stream, one collection at a time. Progress is logged every 500 MB.
- Re-running is safe. Collections whose folder already exists are not downloaded again; only their
metadata.jsonldURLs are rewritten (which also fixes folders downloaded by older versions). An interrupted download restarts that collection from the beginning, because the ZIP is generated on the fly and cannot be resumed. - The access token is checked when each collection's download starts. If later collections fail with 401, sign in again and re-run; finished collections are skipped.
run and the sound/text example scripts write:
output/
├── dataset.jsonld # Croissant JSON-LD with local file paths
└── files/
├── 1367_combined_seg0001_151267-152333.wav # audio file
├── 1367_combined_seg0001_151267-152333.txt # its transcription
├── report.txt # text file (served as plain text, even for PDFs)
└── ...
Files keep their original sc:name, so each recording sits next to its transcript. If two files in one download share a name, the second gets its file id appended (e.g. report-file-1a2b3c4d5e6f7a8b.txt).
After downloading, each sc:contentUrl in the JSON-LD (including those of nested ddpv:transcription objects) is rewritten from the remote URL to the absolute local path, e.g.:
"sc:contentUrl": "http://localhost:5000/api/files/file-1a2b3c4d5e6f7a8b"
→
"sc:contentUrl": "/Users/you/output/files/report.txt"
import json
import pandas as pd
# Load the JSON-LD
with open("output/dataset.jsonld") as f:
data = json.load(f)
# Flatten all file objects across every dataset/year into a DataFrame
rows = []
for dataset_node in data.get("@graph", []):
dataset_id = dataset_node.get("@id")
year = dataset_node.get("sc:temporalCoverage")
for file_obj in dataset_node.get("distribution", []):
rows.append({
"dataset_id": dataset_id,
"year": year,
"file_id": file_obj.get("@id"),
"name": file_obj.get("sc:name"),
"author": file_obj.get("sc:author"),
"local_path": file_obj.get("sc:contentUrl"),
"size_bytes": file_obj.get("sc:contentSize"),
"word_count": file_obj.get("sc:wordCount"),
"token_count": file_obj.get("ddpv:tokenCount"),
"keywords": file_obj.get("sc:keywords"),
"themes": file_obj.get("dcat:theme"),
})
df = pd.DataFrame(rows)
print(df.head())
print(f"\nTotal files: {len(df)}")
print(f"Total tokens: {df['token_count'].sum():,}")import json
from datasets import Dataset
with open("output/dataset.jsonld") as f:
data = json.load(f)
# Build a flat list of records
records = []
for dataset_node in data.get("@graph", []):
for file_obj in dataset_node.get("distribution", []):
records.append({
"file_id": file_obj["@id"],
"name": file_obj.get("sc:name"),
"author": file_obj.get("sc:author"),
"year": dataset_node.get("sc:temporalCoverage"),
"local_path": file_obj.get("sc:contentUrl"),
"word_count": file_obj.get("sc:wordCount"),
"token_count": file_obj.get("ddpv:tokenCount"),
"keywords": ", ".join(file_obj.get("sc:keywords", [])),
"themes": ", ".join(file_obj.get("dcat:theme", [])),
})
ds = Dataset.from_list(records)
print(ds)
print(ds[0])
# Filter, shuffle, split
ds_health = ds.filter(lambda r: "Health" in r["themes"])
train_test = ds_health.train_test_split(test_size=0.2)
print(train_test)from ddplrll_reader import DdplrllDatasetClient, Settings
import pandas as pd
# First, paste your access token into tokens.json (see "Authentication").
# 1. Query and download
client = DdplrllDatasetClient(Settings(
api_base_url="https://lrldtmetadataqa.worldbank.org",
))
jsonld_path = client.run(keyword="health", year=2023, limit=50)
# 2. Load into pandas
import json
with open(jsonld_path) as f:
data = json.load(f)
rows = [
{
"name": fo.get("sc:name"),
"author": fo.get("sc:author"),
"words": fo.get("sc:wordCount"),
"tokens": fo.get("ddpv:tokenCount"),
"themes": fo.get("dcat:theme"),
"path": fo.get("sc:contentUrl"),
}
for node in data.get("@graph", [])
for fo in node.get("distribution", [])
]
df = pd.DataFrame(rows)
# 3. Analyse
print(df.describe())
print(df.groupby("author")["tokens"].sum().sort_values(ascending=False))MIT