Skip to content

Commit 60b2202

Browse files
taheeraahmedfrafra
andcommitted
chg(coat): coat pipeline
Co-authored-by: Francesco Frassinelli <francesco.frassinelli@nina.no>
1 parent 4a0d10e commit 60b2202

2 files changed

Lines changed: 241 additions & 14 deletions

File tree

src/datasync/coat.py

Lines changed: 240 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
from datetime import datetime
2+
from urllib.parse import urlparse
3+
14
import dlt
25
import typer
36
from dlt.destinations.impl.filesystem.factory import filesystem
@@ -10,6 +13,30 @@
1013

1114
app = typer.Typer(help="Export COAT data to parquet")
1215

16+
PAGE_SIZE = 100
17+
BASE_URL = "https://data.coat.no"
18+
ENDPOINT_URL = BASE_URL + "/api/3/action"
19+
20+
SPLIT = (
21+
"associated_parties",
22+
"datasets",
23+
"funding",
24+
"location",
25+
"persons",
26+
"protocol",
27+
"scientific_name",
28+
)
29+
DATE_FIELDS = ("embargo", "temporal_start", "temporal_end")
30+
31+
# Some CKAN resources duplicate metadata under differently-cased "extra"
32+
# keys (e.g. "Size" alongside "size"). Maps the extra key to the native
33+
# field it should fill in when the native field is missing.
34+
_RESOURCE_FIELD_OVERRIDES = {
35+
"Size": "size",
36+
"Created": "created",
37+
"Media type": "mimetype",
38+
}
39+
1340
PLAUSIBLE_API_URL = "https://plausible.io/api/v2/query"
1441
PLAUSIBLE_METRICS = ["visitors", "visits", "pageviews", "bounce_rate", "visit_duration"]
1542
PLAUSIBLE_SITE_ID = "data.coat.no"
@@ -24,6 +51,217 @@
2451
]
2552

2653

54+
def s3_filesystem_destination(
55+
endpoint_url: str,
56+
access_key: str,
57+
secret_key: str,
58+
bucket: str,
59+
prefix: str,
60+
region: str,
61+
):
62+
"""Build an S3 bucket URL and dlt filesystem destination from credentials."""
63+
bucket_url = f"s3://{bucket}/{prefix}"
64+
credentials = AwsCredentials(
65+
s3_url_style="path",
66+
endpoint_url=endpoint_url,
67+
aws_access_key_id=access_key,
68+
aws_secret_access_key=secret_key,
69+
region_name=region,
70+
)
71+
destination = filesystem(
72+
bucket_url=bucket_url,
73+
credentials=credentials,
74+
layout="{table_name}.{ext}",
75+
)
76+
return bucket_url, destination
77+
78+
79+
def normalize_record(record, domain: str = BASE_URL):
80+
"""Normalize a package record in-place."""
81+
org = record.get("organization") or {}
82+
for field in SPLIT:
83+
value = record.get(field)
84+
record[field] = [v.strip() for v in value.split(",")] if value else []
85+
86+
# Parse date fields
87+
for field in DATE_FIELDS:
88+
value = record.get(field)
89+
if value:
90+
record[field] = datetime.strptime(value, "%Y-%m-%d").date()
91+
92+
# Extract organization info
93+
record["organization_name"] = org.get("name")
94+
record["organization_title"] = org.get("title")
95+
96+
# Flatten extras to JSON object
97+
record["extras"] = {e["key"]: e["value"] for e in record.get("extras", [])}
98+
record["extras_base_name"] = record["extras"].get("base_name")
99+
100+
# Extract tag names
101+
record["tags"] = [t["name"] for t in record.get("tags", [])]
102+
103+
# List of resource IDs belonging to this package, e.g. "[id1, id2]"
104+
resource_ids = [r["id"] for r in record.get("resources", []) if r.get("id")]
105+
record["resources_ids"] = f"[{', '.join(resource_ids)}]"
106+
107+
# Build URL
108+
name = record.get("name")
109+
if not name:
110+
log.warning(
111+
f"Record {record.get('id')!r} is missing 'name', URL will be incomplete"
112+
)
113+
record["url"] = f"{domain}/dataset/{name or ''}"
114+
115+
return record
116+
117+
118+
@dlt.resource(
119+
name="packages",
120+
primary_key="id",
121+
write_disposition="replace",
122+
)
123+
def packages(
124+
api_key: str = "",
125+
base_url: str = ENDPOINT_URL,
126+
):
127+
"""Fetch packages from CKAN API and normalize them."""
128+
log.info(f"Starting package extraction from {base_url}")
129+
130+
parsed = urlparse(base_url)
131+
if not parsed.scheme or not parsed.netloc:
132+
log.error("Invalid base_url: %s", base_url)
133+
raise ValueError(f"Invalid base_url: {base_url!r}")
134+
domain = f"{parsed.scheme}://{parsed.netloc}"
135+
136+
client = RESTClient(
137+
base_url=base_url,
138+
headers={"Authorization": api_key, "Accept": "application/json"},
139+
)
140+
141+
paginator = OffsetPaginator(
142+
limit=PAGE_SIZE,
143+
offset_param="start",
144+
limit_param="rows",
145+
total_path="result.count",
146+
)
147+
148+
total_records = 0
149+
for page in client.paginate(
150+
"/ckan_package_search",
151+
params={"include_private": "true"},
152+
paginator=paginator,
153+
):
154+
for record in page:
155+
yield normalize_record(record, domain=domain)
156+
total_records += 1
157+
158+
log.info(f"Extraction complete. Total records: {total_records}")
159+
160+
161+
@dlt.transformer(
162+
data_from=packages,
163+
name="resources",
164+
primary_key="id",
165+
write_disposition="replace",
166+
)
167+
def resources(pkg):
168+
"""Extract resources from packages."""
169+
for res in pkg.get("resources", []):
170+
res = dict(res)
171+
res.pop("__force", None)
172+
173+
# Some resources carry duplicate metadata under differently-cased
174+
# keys (e.g. "Size" alongside "size"). These would otherwise
175+
# collide once column names are normalized downstream, silently
176+
# dropping whichever value loses. Fill the native field from the
177+
# extra one only when the native field is missing.
178+
for extra_field, native_field in _RESOURCE_FIELD_OVERRIDES.items():
179+
value = res.pop(extra_field, None)
180+
if value not in (None, "None") and not res.get(native_field):
181+
res[native_field] = value
182+
183+
yield {
184+
**res,
185+
"package_name": pkg.get("name"),
186+
"package_id": pkg.get("id"),
187+
}
188+
189+
190+
@dlt.source(name="coat", max_table_nesting=0)
191+
def coat_source(api_key: str = "", base_url: str = ENDPOINT_URL):
192+
"""Define the COAT data source."""
193+
return packages(api_key=api_key, base_url=base_url), resources
194+
195+
196+
@app.command()
197+
def get_packages_and_resources(
198+
endpoint_url: str = typer.Option(
199+
...,
200+
envvar="COAT_AWS_ENDPOINT",
201+
help="AWS S3 endpoint URL",
202+
),
203+
access_key: str = typer.Option(
204+
...,
205+
envvar="COAT_AWS_ACCESS_KEY",
206+
help="AWS S3 access key",
207+
),
208+
secret_key: str = typer.Option(
209+
...,
210+
envvar="COAT_AWS_SECRET_KEY",
211+
help="AWS S3 secret key",
212+
),
213+
bucket: str = typer.Option(
214+
...,
215+
envvar="COAT_AWS_BUCKET",
216+
help="AWS S3 bucket name",
217+
),
218+
prefix: str = typer.Option(
219+
default="coat",
220+
envvar="COAT_S3_PREFIX",
221+
help="AWS S3 prefix (folder path) for storing data",
222+
),
223+
region: str = typer.Option(
224+
default="us-east-1",
225+
envvar="COAT_S3_REGION",
226+
help="AWS S3 region",
227+
),
228+
api_key: str = typer.Option(
229+
...,
230+
envvar="COAT_API_KEY",
231+
help="CKAN API key",
232+
),
233+
base_url: str = typer.Option(
234+
default="https://data.coat.no/api/3/action",
235+
envvar="COAT_BASE_URL",
236+
help="COAT CKAN API base URL",
237+
),
238+
dataset_name: str = typer.Option(
239+
default="coat",
240+
envvar="COAT_DATASET_NAME",
241+
help="Local pipeline name (used for dlt's local working/state directory)",
242+
),
243+
):
244+
"""Run the COAT extraction pipeline."""
245+
bucket_url, filesystem_destination = s3_filesystem_destination(
246+
endpoint_url, access_key, secret_key, bucket, prefix, region
247+
)
248+
249+
pipeline = dlt.pipeline(
250+
pipeline_name=dataset_name,
251+
destination=filesystem_destination,
252+
dataset_name="coat_resources",
253+
)
254+
run = pipeline.run(
255+
coat_source(api_key=api_key, base_url=base_url), loader_file_format="parquet"
256+
)
257+
log.info(
258+
f"COAT pipeline output written to:\n"
259+
f"- {bucket_url}/coat_resources/resources.parquet\n"
260+
f"- {bucket_url}/coat_resources/packages.parquet"
261+
)
262+
log.info(f"Pipeline run completed. Load info: {run}")
263+
264+
27265
def plausible_paginate(api_key: str, base_payload: dict, page_size: int = 10_000):
28266
"""Yield all result rows from a Plausible v2 query using dlt pagination."""
29267
client = RESTClient(
@@ -148,19 +386,8 @@ def get_plausible_analytics(
148386
),
149387
):
150388
"""Run the Plausible analytics pipeline for data.coat.no."""
151-
bucket_url = f"s3://{bucket}/{prefix}"
152-
credentials = AwsCredentials(
153-
s3_url_style="path",
154-
endpoint_url=endpoint_url,
155-
aws_access_key_id=access_key,
156-
aws_secret_access_key=secret_key,
157-
region_name=region,
158-
)
159-
160-
filesystem_destination = filesystem(
161-
bucket_url=bucket_url,
162-
credentials=credentials,
163-
layout="{table_name}.{ext}",
389+
bucket_url, filesystem_destination = s3_filesystem_destination(
390+
endpoint_url, access_key, secret_key, bucket, prefix, region
164391
)
165392

166393
pipeline = dlt.pipeline(

src/datasync/main.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
app = typer.Typer(
2222
help="Provide subcommands for synchronizing different resources, see subcommands"
2323
)
24+
app.add_typer(coat.app, name="coat")
2425
app.add_typer(nva.app, name="nva")
2526
app.add_typer(ubw.app, name="ubw")
2627
app.add_typer(dms.app, name="dms")
@@ -31,7 +32,6 @@
3132
app.add_typer(gbif_backbone.app, name="gbif-backbone")
3233
app.add_typer(ipt_app, name="ipt")
3334
app.add_typer(mediebank.app, name="mediebank")
34-
app.add_typer(coat.app, name="coat")
3535

3636
if __name__ == "__main__":
3737
app()

0 commit comments

Comments
 (0)