update-subjects-from-table/update_subject_records.py

272 lines
7.4 KiB
Python

# /// script
# requires-python = ">=3.12"
# dependencies = [
# "dump-things-pyclient @ https://hub.psychoinformatics.de/datalink/dump-things-pyclient.git",
# "things-enrichment-tools @ https://hub.psychoinformatics.de/orinoco/things-enrichment-tools.git",
# "click",
# ]
# ///
import csv
import re
import sys
from os import environ
import click
from dump_things_pyclient.communicate import (
collection_read_record_with_pid,
collection_write_record,
get_session,
)
import things_enrichment_tools as tet
# PID anchor is the things_enrichment_tools repo/dataset UUID
self_id = 'datalad:10e82c66-d2ca-4f8f-884e-f9a5728eed1d/instruments/trr379-q01-subject-importer'
def read_tsv(path):
with open(path, newline='', encoding='utf-8') as f:
reader = csv.DictReader(f, delimiter='\t')
for row in reader:
yield dict(row)
def proc_sub(
sub_id: str,
group_id: str | None,
*,
study_pid: str,
table_pid: str,
pool_api_url: str,
pool_collection: str,
sub_pid_prefix: str,
token: str,
session,
) -> None:
""" """
modified = set()
owner_props = {'source_id': table_pid, 'owner_id': self_id}
pid = f'{sub_pid_prefix}{sub_id}'
rec = collection_read_record_with_pid(
pool_api_url,
pool_collection,
pid,
format='json',
token=token,
session=session,
) or {'pid': pid}
modified.add(
tet.update_data_property(
rec,
collection_slot='characterized_by',
predicate='dcterms:type',
value_key='object',
topical_slot='kind',
value='obo:NCBITaxon_9606',
**owner_props,
)
)
modified.add(
tet.update_data_property(
rec,
collection_slot='characterized_by',
predicate='dlthings:study',
value_key='object',
topical_slot='study',
value=study_pid,
**owner_props,
)
)
modified.add(
tet.update_object_property(
rec,
slot='generated_by',
value={'object': study_pid},
**owner_props,
)
)
if group_id:
modified.add(
tet.update_multivalued_object_property(
rec,
slot='influenced_by',
values=[{'object': group_id, 'roles': ['obo:OBI_0000174']}],
**owner_props,
)
)
modified.add(
tet.update_multivalued_object_property(
rec,
slot='identifiers',
values=[{'creator': 'trr379root:projects/q01', 'notation': sub_id}],
**owner_props,
)
)
if True not in modified:
return False
collection_write_record(
pool_api_url,
pool_collection,
class_name='TRR379Subject',
record=rec,
format='json',
token=token,
session=session,
)
return True
@click.command()
@click.option(
"--subid-colname",
default='trr-sub',
show_default=True,
help="name of the TSV column holding the subject identifiers",
)
@click.option(
"--grpid-colname",
default='recr-group',
show_default=True,
help="name of the TSV column holding the recruiting group identifiers",
)
@click.option(
"--recr-group", 'recr_groups',
type=(str, str),
multiple=True,
help="code/id pair; may be given multiple times.",
)
@click.option(
"--subid-expr",
default=r'^[A-Z]\d\d\d\d$',
show_default=True,
help="Regular expression any subject identifier must match.",
)
@click.option(
"--subpid-prefix",
default='trr379:subjects/',
show_default=True,
help='prefix to prepend to the subject identifier to form the record PID',
)
@click.option(
'--pool-api-url',
default='https://pool.v0.trr379.de/api',
help='Knowledge pool API URL to receive the subject record submission.',
show_default=True,
)
@click.option(
'--pool-collection',
default='protected',
help='Target knowledge pool record collection',
show_default=True,
)
@click.argument(
'study_pid',
)
@click.argument(
'table_pid',
)
@click.argument(
'infile',
)
def main(
infile: str,
study_pid: str,
table_pid: str,
pool_api_url: str,
pool_collection: str,
recr_groups: tuple[tuple[str, str]],
subid_expr: str,
subid_colname: str,
grpid_colname: str,
subpid_prefix: str,
) -> None:
"""Read participant information and submit (updated) subject records
Three arguments are required (in order):
\b
1. study PID
2. table PID
3. input file path
The input file must be in tab-separated value (TSV). Two items of
information are read from each row in this table (one row per participant):
\b
- pseudonymized subject identifier (`trr-sub` column)
- subject recruiting group code (`recr-group` column)
Name and conventions for this information matches the agreement for ID
broker table formatting in TRR379.
The study PID identifies the the study (part) from which the imported
information originate. It should match an existing knowledge pool `Study`
record. For example, the value `trr379:studies/q01-fp1-aachen` would expand
to `https://trr379.de/ns/studies/q01-fp1-aachen`.
The table PID identifiers the specific version of the source table. For
files tracked in Git, this can be the content blob SHA (prefixed with
`dldi:`). Alternatively, an ISO date string can be given.
"""
errored = False
session = get_session()
sub_id_re = re.compile(subid_expr)
recr_group_map = dict(recr_groups)
token = environ['DTC_TOKEN']
for row in read_tsv(infile):
sub_id = row[subid_colname]
# take off BIDS prefix
if sub_id.startswith('sub-'):
sub_id = sub_id[4:]
# minimum sanity-checking to avoid spamming the knowledge pool
if not sub_id_re.match(sub_id):
print( # noqa: T201
f'ERROR: {sub_id!r} is not a valid subject identifier',
file=sys.stderr,
)
errored = True
continue
group_id = row[grpid_colname]
# map to standard terminology
if group_id:
try:
group_id = recr_group_map[group_id]
except KeyError:
print( # noqa: T201
f'IGNORE: {sub_id!r} {grpid_colname} {group_id!r} unrecognized',
file=sys.stderr,
)
group_id = None
errored = True
else:
group_id = None
if proc_sub(
sub_id,
group_id,
study_pid=study_pid,
table_pid=table_pid,
pool_api_url=pool_api_url,
pool_collection=pool_collection,
sub_pid_prefix=subpid_prefix,
token=token,
session=session,
):
print(f'OK: {sub_id!r} submitted', file=sys.stderr) # noqa: T201
else:
# we do not issue those, because they are the majority and drown
# everything else in them, but the others are the key messages.
#print(f'SKIP: {sub_id!r} exists and is unmodified', file=sys.stderr) # noqa: T201
pass
if errored:
print('ERROR: see report(s) above', file=sys.stderr) # noqa: T201
sys.exit(1)
if __name__ == '__main__':
main()