Skip to content

Commit 2895d3d

Browse files
committed
feat: draft - ipt dwca function
1 parent 9bca02c commit 2895d3d

13 files changed

Lines changed: 765 additions & 10 deletions

File tree

pyproject.toml

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,24 @@ dependencies = [
1616
"structlog>=25.5.0",
1717
"rich>=14.2.0",
1818
"typer>=0.20.0",
19-
"pygeometa>=0.19.0",
19+
"pygeometa @ git+https://github.com/nicokant/pygeometa@gbif-eml#egg=pygeometa",
2020
"orjson>=3.11.4",
2121
"duckdb>=1.4.2",
2222
"lxml>=6.0.2",
2323
"openpyxl>=3.1.5",
2424
"python-calamine>=0.6.1",
2525
"requests>=2.32.5",
26-
"sling>=1.5.1"
26+
"sling>=1.5.1",
27+
"beautifulsoup4>=4.13.3",
28+
"fsspec>=2025.3.2",
29+
"jinja2>=3.1.6",
30+
"s3fs>=2025.3.2",
31+
"shapely>=2.1.0",
32+
"xmltodict>=0.14.2",
33+
"pyarrow>=20.0.0",
34+
"httpx>=0.28.1",
35+
"setuptools>=65.5.0",
36+
"backoff>=2.1.0"
2737
]
2838
description = ""
2939
license = "GPL-3.0+"
@@ -39,7 +49,10 @@ nva_sync = "datasync.nva:app"
3949
ubw_sync = "datasync.ubw:app"
4050

4151
[tool.deptry.per_rule_ignores]
42-
DEP002 = ["rich", "numpy"]
52+
DEP002 = ["rich", "numpy", "setuptools"]
53+
54+
[tool.hatch.metadata]
55+
allow-direct-references = true
4356

4457
[tool.ruff]
4558
fix = true

src/datasync/ipt/__init__.py

Whitespace-only changes.

src/datasync/ipt/csw.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import json
2+
3+
import pyarrow as pa
4+
from lxml import etree
5+
from pygeometa.schemas.gbif_eml import GBIF_EMLOutputSchema
6+
from pygeometa.schemas.iso19139 import ISO19139OutputSchema
7+
from shapely.geometry import box
8+
9+
from ..settings import env as logger
10+
from .settings import (
11+
AWS_ENDPOINT_URL,
12+
CSW_PATH,
13+
GEOAPI_PUBLISH_URL,
14+
IPT_URL,
15+
RESOURCES_PREFIX,
16+
S3_BUCKET,
17+
conn,
18+
)
19+
20+
PARSER = etree.XMLParser(resolve_entities=False)
21+
eml = GBIF_EMLOutputSchema()
22+
iso = ISO19139OutputSchema()
23+
24+
25+
def get_anytext(bag):
26+
"""
27+
generate bag of text for free text searches
28+
accepts list of words, string of XML, or etree.Element
29+
"""
30+
31+
if isinstance(bag, list): # list of words
32+
return " ".join([_f for _f in bag if _f]).strip()
33+
else: # xml
34+
if isinstance(bag, bytes) or isinstance(bag, str):
35+
# serialize to lxml
36+
bag = etree.fromstring(bag, PARSER) # noqa: S320
37+
# get all XML element content
38+
return " ".join([value.strip() for value in bag.xpath("//text()")])
39+
40+
41+
def eml_to_record(ds, text):
42+
metadata = eml.import_(text)
43+
44+
metadata["metadata"]["identifier"] = f"ipt__{ds['id']}"
45+
46+
xml = iso.write(metadata)
47+
fts = get_anytext(xml)
48+
idf = metadata["identification"]
49+
bbox = idf["extents"]["spatial"][0]["bbox"]
50+
51+
contribs = []
52+
for role, contact in metadata["contact"].items():
53+
role = role.split("_")[0]
54+
contribs.append(contact["individualname"])
55+
56+
keywords = []
57+
for _k, v in idf["keywords"].items():
58+
keywords += v["keywords"]
59+
60+
if ds.get("ipt_dwca"):
61+
links = [
62+
{
63+
"name": "Parquet",
64+
"description": "The resource as (geo)parquet file",
65+
"protocol": "FILE:GEO",
66+
"url": f"{AWS_ENDPOINT_URL}/{S3_BUCKET}{RESOURCES_PREFIX}{ds['id']}.parquet", # noqa: E501
67+
},
68+
{
69+
"name": "DWCA",
70+
"description": "The resource as Darwin Core Archive",
71+
"protocol": "file",
72+
"url": f"{IPT_URL}/archive.do?r={ds['id']}", # noqa: E501
73+
},
74+
]
75+
76+
if GEOAPI_PUBLISH_URL:
77+
links.append(
78+
{
79+
"name": "OGC API Feature",
80+
"description": "OGC REST API to the resource",
81+
"protocol": "OGCFeat",
82+
"url": f"{GEOAPI_PUBLISH_URL}/collections/ipt__{ds['id']}/items?f=json", # noqa: E501
83+
},
84+
)
85+
else:
86+
links = []
87+
88+
return {
89+
"identifier": metadata["metadata"]["identifier"],
90+
"typename": "gmd:MD_Metadata",
91+
"schema": "http://www.isotc211.org/2005/gmd",
92+
"mdsource": "local",
93+
"insert_date": idf["dates"]["publication"],
94+
"title": ds["title"],
95+
"date_modified": idf["dates"]["publication"],
96+
"type": "dataset",
97+
"format": None,
98+
"wkt_geometry": box(*bbox).wkt,
99+
"metadata": xml,
100+
"xml": xml,
101+
"keywords": ", ".join(set(keywords)),
102+
"metadata_type": "application/xml",
103+
"anytext": fts,
104+
"abstract": metadata["identification"]["abstract"],
105+
"date": idf["dates"]["publication"],
106+
"creator": "Norsk institutt for naturforskning (NINA)",
107+
"publisher": "Norsk institutt for naturforskning (NINA)",
108+
"contributor": "; ".join(set(contribs)),
109+
"links": json.dumps(links),
110+
}
111+
112+
113+
def write_eml_record(rows):
114+
logger.info("converting to arrow")
115+
records = pa.Table.from_pylist(rows) # noqa: F841
116+
logger.info("write to S3")
117+
conn.sql("from records").write_parquet(
118+
f"s3://{S3_BUCKET}{CSW_PATH}",
119+
compression="zstd",
120+
overwrite=True,
121+
)

src/datasync/ipt/dms.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from ..libs.dms import upsert_dms_element
2+
from .settings import DMS_PROJECT_ID
3+
4+
5+
def create_dms_dataset(ds, parquet_url):
6+
ds_id = "IPT__" + ds["id"]
7+
8+
# Upsert dataset
9+
dataset_data = {
10+
"id": ds_id,
11+
"title": ds["title"],
12+
"metadata": {},
13+
"version": "latest",
14+
"project_id": DMS_PROJECT_ID,
15+
}
16+
dataset_update_data = {
17+
"title": ds["title"],
18+
"version": "latest",
19+
}
20+
21+
upsert_dms_element("datasets", ds_id, dataset_data, dataset_update_data)
22+
23+
if parquet_url:
24+
# Upsert tabular resource
25+
tabular_resource_data = {
26+
"id": ds_id + "_parquet",
27+
"dataset_id": ds_id,
28+
"title": ds["title"] + " Parquet",
29+
"uri": parquet_url,
30+
"access_type": "public",
31+
"role": "data",
32+
}
33+
tabular_resource_update_data = {
34+
"title": ds["title"] + " Parquet",
35+
"uri": parquet_url,
36+
}
37+
38+
upsert_dms_element(
39+
"tabularresources",
40+
ds_id + "_parquet",
41+
tabular_resource_data,
42+
tabular_resource_update_data,
43+
)
44+
45+
# Upsert resource
46+
resource_data = {
47+
"id": ds_id + "_dwca",
48+
"dataset_id": ds_id,
49+
"title": ds["title"] + " DWCA",
50+
"uri": ds["ipt_dwca"],
51+
"access_type": "public",
52+
"role": "data",
53+
}
54+
resource_update_data = {
55+
"title": ds["title"] + " DWCA",
56+
"uri": ds["ipt_dwca"],
57+
}
58+
59+
upsert_dms_element(
60+
"resources", ds_id + "_dwca", resource_data, resource_update_data
61+
)

src/datasync/ipt/dwca.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import pathlib
2+
import re
3+
4+
import fsspec
5+
from bs4 import BeautifulSoup
6+
7+
8+
class SourceLayer:
9+
def __init__(self, node, base_path, extension=False) -> None:
10+
self.type = pathlib.Path(node.find("location").text).stem
11+
self.headers = []
12+
self.path = f"zip://{base_path}/{node.find('location').text}"
13+
14+
with fsspec.open(
15+
f"zip://{node.find('location').text}::{base_path}",
16+
encoding=node["encoding"],
17+
mode="r",
18+
) as f:
19+
sep = re.compile(node["fieldsTerminatedBy"])
20+
self.headers = re.split(sep, f.readline().rstrip())
21+
22+
# extensions nodes have a "coreid" fields that contains the id
23+
# of "core" row, this is needed for the join
24+
id_field_lookup = "coreid" if extension else "id"
25+
self.id = self.headers[int(node.find(id_field_lookup)["index"])]
26+
27+
def __str__(self) -> str:
28+
return self.type
29+
30+
31+
def get_context_from_metafile(resource_path: str):
32+
meta_file = fsspec.open(f"zip://meta.xml::{resource_path}")
33+
with meta_file as meta:
34+
soup = BeautifulSoup(meta, features="lxml-xml")
35+
extensions = []
36+
core = SourceLayer(soup.find("core"), resource_path)
37+
all_columns = set(core.headers)
38+
39+
for extension in soup.find_all("extension"):
40+
ext = SourceLayer(extension, resource_path, extension=True)
41+
extensions.append(ext)
42+
all_columns.union(ext.headers)
43+
44+
return {"core": core, "extensions": extensions, "columns": all_columns}

src/datasync/ipt/geoapi.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import pyarrow as pa
2+
from lxml import etree
3+
from pygeometa.schemas.gbif_eml import GBIF_EMLOutputSchema
4+
5+
from ..settings import env as logger
6+
from .settings import AWS_ENDPOINT_URL, GEOAPI_PATH, RESOURCES_PREFIX, S3_BUCKET, conn
7+
8+
PARSER = etree.XMLParser(resolve_entities=False)
9+
eml = GBIF_EMLOutputSchema()
10+
11+
12+
def to_pygeoapi_resource(ds, eml_text):
13+
metadata = eml.import_(eml_text)
14+
15+
idf = metadata["identification"]
16+
spatial = idf["extents"]["spatial"][0]
17+
18+
contribs = []
19+
for role, contact in metadata["contact"].items():
20+
role = role.split("_")[0]
21+
contribs.append(contact["individualname"])
22+
23+
keywords = []
24+
for _k, v in idf["keywords"].items():
25+
keywords += v["keywords"]
26+
27+
return {
28+
"id": f"ipt__{ds['id']}",
29+
"type": "collection",
30+
"visibility": "default",
31+
"title": ds["title"],
32+
"extents": {"spatial": spatial},
33+
"keywords": list(set(keywords)),
34+
"description": metadata["identification"]["abstract"],
35+
"providers": [
36+
{
37+
"type": "feature",
38+
"name": "OGR",
39+
"default": True,
40+
"id_field": "fid",
41+
"editable": False,
42+
"storage_crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84",
43+
"crs": [
44+
"http://www.opengis.net/def/crs/OGC/1.3/CRS84",
45+
"http://www.opengis.net/def/crs/EPSG/0/4326",
46+
],
47+
"data": {
48+
"source_type": "Parquet",
49+
"source": f"/vsicurl/{AWS_ENDPOINT_URL}/{S3_BUCKET}{RESOURCES_PREFIX}{ds['id']}.parquet", # noqa: E501
50+
},
51+
"layer": ds["id"],
52+
}
53+
],
54+
}
55+
56+
57+
def write_pygeoapi_resources(rows):
58+
logger.info("converting to arrow")
59+
records = pa.Table.from_pylist(rows) # noqa: F841
60+
logger.info("write to S3")
61+
conn.sql(f"""
62+
COPY records to 's3://{S3_BUCKET}{GEOAPI_PATH}' (FORMAT json, ARRAY true)
63+
""") # noqa: E501

src/datasync/ipt/ipt.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import httpx
2+
import xmltodict
3+
from bs4 import BeautifulSoup
4+
5+
from .settings import IPT_URL
6+
7+
8+
def get_datasets():
9+
res = httpx.get(f"{IPT_URL}/rss")
10+
soup = BeautifulSoup(res.text, features="lxml-xml")
11+
for item in soup.find_all("item"):
12+
content = {
13+
k.replace(":", "_"): v
14+
for k, v in xmltodict.parse(item.prettify())["item"].items()
15+
}
16+
resource_id = content["link"].split("=")[1]
17+
yield {
18+
**content,
19+
"id": resource_id,
20+
"version": content["guid"]["#text"].split("/")[1].replace("v", ""),
21+
}
22+
23+
24+
def get_dataset_metadata(resource_id: str):
25+
url = IPT_URL + "/eml.do?r=" + resource_id
26+
res = httpx.get(url)
27+
return res.text

0 commit comments

Comments
 (0)