fairscape-conversion 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fairscape_conversion/MAPPING-SCHEMA.md +216 -0
- fairscape_conversion/README.md +73 -0
- fairscape_conversion/__init__.py +1 -0
- fairscape_conversion/core/__init__.py +33 -0
- fairscape_conversion/core/arks.py +30 -0
- fairscape_conversion/core/cli.py +54 -0
- fairscape_conversion/core/engine.py +226 -0
- fairscape_conversion/core/loader.py +122 -0
- fairscape_conversion/core/parsers.py +54 -0
- fairscape_conversion/core/plugin.py +182 -0
- fairscape_conversion/core/records.py +99 -0
- fairscape_conversion/core/registry.py +55 -0
- fairscape_conversion/core/roundtrip.py +97 -0
- fairscape_conversion/core/schema.py +138 -0
- fairscape_conversion/plugins/__init__.py +1 -0
- fairscape_conversion/plugins/c2m2/__init__.py +29 -0
- fairscape_conversion/plugins/c2m2/associations.csv +43 -0
- fairscape_conversion/plugins/c2m2/computed.csv +5 -0
- fairscape_conversion/plugins/c2m2/constants.csv +39 -0
- fairscape_conversion/plugins/c2m2/cv_bases.csv +19 -0
- fairscape_conversion/plugins/c2m2/entities.csv +19 -0
- fairscape_conversion/plugins/c2m2/golden.json +1167 -0
- fairscape_conversion/plugins/c2m2/impl.py +547 -0
- fairscape_conversion/plugins/c2m2/input-datapackage/C2M2_datapackage.json +135 -0
- fairscape_conversion/plugins/c2m2/input-datapackage/biosample.tsv +2 -0
- fairscape_conversion/plugins/c2m2/input-datapackage/file.tsv +3 -0
- fairscape_conversion/plugins/c2m2/input-datapackage/file_describes_biosample.tsv +2 -0
- fairscape_conversion/plugins/c2m2/input-datapackage/file_format.tsv +2 -0
- fairscape_conversion/plugins/c2m2/input-datapackage/id_namespace.tsv +2 -0
- fairscape_conversion/plugins/c2m2/input-datapackage/project.tsv +2 -0
- fairscape_conversion/plugins/c2m2/input-datapackage/subject.tsv +1 -0
- fairscape_conversion/plugins/c2m2/mapper.py +386 -0
- fairscape_conversion/plugins/c2m2/ontology.py +140 -0
- fairscape_conversion/plugins/c2m2/parsers.py +371 -0
- fairscape_conversion/plugins/c2m2/properties.csv +122 -0
- fairscape_conversion/plugins/c2m2/root.json +108 -0
- fairscape_conversion/plugins/croissant/__init__.py +41 -0
- fairscape_conversion/plugins/croissant/entities.csv +5 -0
- fairscape_conversion/plugins/croissant/golden.json +611 -0
- fairscape_conversion/plugins/croissant/impl.py +95 -0
- fairscape_conversion/plugins/croissant/input.json +1167 -0
- fairscape_conversion/plugins/croissant/parsers.py +52 -0
- fairscape_conversion/plugins/croissant/properties.csv +43 -0
- fairscape_conversion/plugins/cromwell/__init__.py +206 -0
- fairscape_conversion/plugins/cromwell/entities.csv +8 -0
- fairscape_conversion/plugins/cromwell/extract.py +344 -0
- fairscape_conversion/plugins/cromwell/golden.json +549 -0
- fairscape_conversion/plugins/cromwell/input.json +174 -0
- fairscape_conversion/plugins/cromwell/parsers.py +259 -0
- fairscape_conversion/plugins/cromwell/properties.csv +56 -0
- fairscape_conversion/plugins/d4d/__init__.py +22 -0
- fairscape_conversion/plugins/d4d/entities.csv +5 -0
- fairscape_conversion/plugins/d4d/golden.json +520 -0
- fairscape_conversion/plugins/d4d/impl.py +327 -0
- fairscape_conversion/plugins/d4d/input.yaml +689 -0
- fairscape_conversion/plugins/d4d/parsers.py +128 -0
- fairscape_conversion/plugins/d4d/properties.csv +78 -0
- fairscape_conversion/plugins/example/__init__.py +45 -0
- fairscape_conversion/plugins/example/entities.csv +2 -0
- fairscape_conversion/plugins/example/golden.json +20 -0
- fairscape_conversion/plugins/example/input.json +16 -0
- fairscape_conversion/plugins/example/properties.csv +4 -0
- fairscape_conversion/plugins/mlflow/__init__.py +204 -0
- fairscape_conversion/plugins/mlflow/entities.csv +8 -0
- fairscape_conversion/plugins/mlflow/extract.py +554 -0
- fairscape_conversion/plugins/mlflow/golden.json +389 -0
- fairscape_conversion/plugins/mlflow/input.json +230 -0
- fairscape_conversion/plugins/mlflow/parsers.py +320 -0
- fairscape_conversion/plugins/mlflow/properties.csv +63 -0
- fairscape_conversion/plugins/snakemake/__init__.py +185 -0
- fairscape_conversion/plugins/snakemake/entities.csv +8 -0
- fairscape_conversion/plugins/snakemake/golden.json +407 -0
- fairscape_conversion/plugins/snakemake/input.json +130 -0
- fairscape_conversion/plugins/snakemake/parsers.py +263 -0
- fairscape_conversion/plugins/snakemake/properties.csv +56 -0
- fairscape_conversion/plugins/wrroc/__init__.py +34 -0
- fairscape_conversion/plugins/wrroc/entities.csv +19 -0
- fairscape_conversion/plugins/wrroc/golden.json +335 -0
- fairscape_conversion/plugins/wrroc/hooks.py +359 -0
- fairscape_conversion/plugins/wrroc/input.json +345 -0
- fairscape_conversion/plugins/wrroc/parsers.py +267 -0
- fairscape_conversion/plugins/wrroc/properties.csv +36 -0
- fairscape_conversion-0.1.0.dist-info/METADATA +89 -0
- fairscape_conversion-0.1.0.dist-info/RECORD +86 -0
- fairscape_conversion-0.1.0.dist-info/WHEEL +5 -0
- fairscape_conversion-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
# Unified converter mapping format
|
|
2
|
+
|
|
3
|
+
One mapping format for every fairscape RO-Crate converter. A converter is a
|
|
4
|
+
**plugin**: a folder of CSVs (the mapping data) plus one subclass of
|
|
5
|
+
`core.plugin.PluginBase` holding the workflow-specific algorithms (named
|
|
6
|
+
parsers + step methods; see `docs/NEW-PLUGIN.md`). The core engine
|
|
7
|
+
(`fairscape_conversion/core/`) reads the CSVs and drives the conversion; you never re-read a
|
|
8
|
+
bespoke format to understand a converter — you read these CSVs.
|
|
9
|
+
|
|
10
|
+
Guiding rule: **if it is data it lives in a CSV; if it is an algorithm it is a
|
|
11
|
+
named parser or hook.** Blank cells are ignored. List-valued cells are
|
|
12
|
+
pipe-joined (`a|b|c`).
|
|
13
|
+
|
|
14
|
+
Every plugin has `entities.csv` + `properties.csv`. Some plugins add
|
|
15
|
+
`associations.csv` and/or `cv_bases.csv` (currently only c2m2 needs them). None
|
|
16
|
+
of these files are required to be non-empty — an export-only plugin may leave
|
|
17
|
+
half the columns blank.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## `entities.csv` — one row per source entity class
|
|
22
|
+
|
|
23
|
+
How a source node/table/record is recognized and what target entity it becomes.
|
|
24
|
+
Row order is match precedence (first matching row wins).
|
|
25
|
+
|
|
26
|
+
| column | meaning |
|
|
27
|
+
|---|---|
|
|
28
|
+
| `source_type` | the source `@type` token, C2M2 table name, or model class name to match |
|
|
29
|
+
| `discriminator` | named predicate that further gates the match (blank = match any). e.g. wrroc `instrument_is_workflow`; the datasheet/croissant `ROOT` vs `COMPONENT` position |
|
|
30
|
+
| `target_type` | the target entity/class produced (empty ⇒ node kept as-is or dropped, see `level`) |
|
|
31
|
+
| `target_type_iri` | pipe-joined `@type` array to stamp on the target node, e.g. `prov:Activity\|https://w3id.org/EVI#Computation` |
|
|
32
|
+
| `level` | role tag consumed by hooks: `workflow`, `tool`, `data`, `software`, `root`, `component`, `agent`, `drop` |
|
|
33
|
+
| `id_strategy` | how the target `@id` is set: `keep` (reuse source id), `mint`, `persistent_or_mint`, `persistent_ark_or_mint`, `ontology_term`, `column` |
|
|
34
|
+
| `id_template` | `{token}`-interpolated mint pattern, e.g. `{prefix}-biosample/{local_id}` |
|
|
35
|
+
| `id_column` | source column/field carrying the id (for `persistent_*`, `ontology_term`, `column`) |
|
|
36
|
+
| `keep_identifier` | `TRUE` ⇒ stash the original source `@id` as `identifier` so a reverse trip can restore it |
|
|
37
|
+
| `mint_prefix` | short stem for minted ARKs (wrroc), e.g. `computation-main` |
|
|
38
|
+
| `note` | free text |
|
|
39
|
+
|
|
40
|
+
`level` semantics: `agent` = kept in the output graph unchanged; `drop` = not
|
|
41
|
+
emitted as an entity (a hook may still harvest data from it); everything else is
|
|
42
|
+
a real target node. The engine mints an `@id` for every non-`agent`/non-`drop`
|
|
43
|
+
node per `id_strategy`.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## `properties.csv` — one row per source-property → target-property
|
|
48
|
+
|
|
49
|
+
One row moves one property, in one or both directions.
|
|
50
|
+
|
|
51
|
+
| column | meaning |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `source_type` | owning source entity (matches an `entities.source_type`) |
|
|
54
|
+
| `source_property` | source field/column read (blank ⇒ constant or synthesized) |
|
|
55
|
+
| `target_type` | owning target entity |
|
|
56
|
+
| `target_property` | target field written |
|
|
57
|
+
| `import_parser` | named parser, source→target direction (blank ⇒ not imported) |
|
|
58
|
+
| `export_parser` | named parser, target→source direction (blank ⇒ not exported) |
|
|
59
|
+
| `direction` | `both`, `import`, or `export` (which directions this row participates in) |
|
|
60
|
+
| `reverse_primary` | `TRUE` ⇒ for a many-to-one target, this source wins on the reverse trip |
|
|
61
|
+
| `requirement` | `required`, `recommended`, `optional`, `synthesized`, `passthrough`, `nohome` (documentation + passthrough behaviour) |
|
|
62
|
+
| `cardinality` | `scalar` or `list` (target cardinality; `list` accumulates all sources, `scalar` is first-write-wins) |
|
|
63
|
+
| `constant_value` | `{token}`-interpolated literal when `source_property` is blank |
|
|
64
|
+
| `fallback_source` | second source field tried when the first is empty |
|
|
65
|
+
| `fallback_parser` | parser applied to `fallback_source` |
|
|
66
|
+
| `source_table` | CV/lookup table this field resolves against (c2m2) |
|
|
67
|
+
| `param_name` | name for a wrapped `PropertyValue` when `target_property` is `additionalProperty` (c2m2) |
|
|
68
|
+
| `wrap` | wrap the value: `ident_ref`, `property_value`, `ruled_out_pv` (blank = none) |
|
|
69
|
+
| `note` | free text |
|
|
70
|
+
|
|
71
|
+
**`requirement` = `passthrough`** rows are copied verbatim as extra keys and
|
|
72
|
+
re-emitted on export (fairscape models allow extra keys). **`nohome`** rows are
|
|
73
|
+
documented drops (a target property with no home the other direction).
|
|
74
|
+
|
|
75
|
+
**Many-to-one merge.** When several `properties.csv` rows share one
|
|
76
|
+
`target_type` + `target_property`, they merge: a `list` target accumulates every
|
|
77
|
+
source's output, a `scalar` target keeps the first non-empty in row order (put
|
|
78
|
+
the `reverse_primary` source first so it wins). This is how d4d collapses
|
|
79
|
+
`intended_uses`, `purposes`, `tasks`, … onto `rai_data_use_cases`.
|
|
80
|
+
|
|
81
|
+
---
|
|
82
|
+
|
|
83
|
+
## `associations.csv` — join-table edges (c2m2 only)
|
|
84
|
+
|
|
85
|
+
C2M2 encodes relationships in link tables (`biosample_disease`, …). Each row is
|
|
86
|
+
one edge rule. The join algorithm itself (build a guid index, look each node up
|
|
87
|
+
by both its natural key and its `@id`) lives in the c2m2 `hooks.py`; this CSV is
|
|
88
|
+
only the rule data.
|
|
89
|
+
|
|
90
|
+
| column | meaning |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `source_type` | entity the edge attaches to |
|
|
93
|
+
| `assoc_table` | the link/association table |
|
|
94
|
+
| `match_columns` | pipe-joined columns forming the owner's key |
|
|
95
|
+
| `match_resolver` | `entity_key` (owner's natural key) or `ontology_term` (owner is a CV node) |
|
|
96
|
+
| `value_columns` | pipe-joined columns carrying the target reference |
|
|
97
|
+
| `value_resolver` | `ontology_term`, `entity_guid`, or `raw` |
|
|
98
|
+
| `value_entity` | for `entity_guid`: the table whose node the value resolves to; for `ontology_term`: the CV source table |
|
|
99
|
+
| `target_property` | pipe-joined target properties the edge writes |
|
|
100
|
+
| `wrap` | `ident_ref`, `property_value`, `ruled_out_pv` |
|
|
101
|
+
| `filter_column` | gate the rows on this column (blank = no filter) |
|
|
102
|
+
| `filter_endswith` | keep rows whose `filter_column` ends with this (`:1` observed, `:0` ruled-out — **the CV code, never the label**) |
|
|
103
|
+
| `param_name` | name for the wrapped `PropertyValue` when `wrap` = `property_value` (the old `pv_name`) |
|
|
104
|
+
| `note` | free text |
|
|
105
|
+
|
|
106
|
+
A single association row is written twice in the CSV to express both an edge and
|
|
107
|
+
its back-reference (swap which columns are `match` vs `value`), exactly as the
|
|
108
|
+
original per-table JSON did.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
## `cv_bases.csv` — controlled-vocabulary → ontology IRI bases (c2m2 only)
|
|
113
|
+
|
|
114
|
+
The prefix/table → ontology-base lookup that `ontology.resolve_term` used to
|
|
115
|
+
hold as Python dicts. Two kinds of row:
|
|
116
|
+
|
|
117
|
+
| column | meaning |
|
|
118
|
+
|---|---|
|
|
119
|
+
| `key` | the CURIE prefix (`kind=curie`, e.g. `DOID`) or the CV table (`kind=bare`, e.g. `gene`) |
|
|
120
|
+
| `kind` | `curie` (value carries a CURIE prefix) or `bare` (table's ids are bare accessions) |
|
|
121
|
+
| `iri_base` | IRI base the local id is appended to (e.g. `http://purl.obolibrary.org/obo/DOID_`) |
|
|
122
|
+
| `ontology_name` | human name of the ontology |
|
|
123
|
+
| `curie_prefix` | for `kind=bare`: the CURIE stem to reattach (e.g. `ensembl`) |
|
|
124
|
+
| `note` | free text |
|
|
125
|
+
|
|
126
|
+
A few resolutions stay in plugin code because they are not simple base lookups:
|
|
127
|
+
the `NCBI:txid…` taxonomy rule and the GlyTouCan-vs-PubChem `compound` split.
|
|
128
|
+
|
|
129
|
+
---
|
|
130
|
+
|
|
131
|
+
## Parsers and plugin methods (the Python that a CSV references by name)
|
|
132
|
+
|
|
133
|
+
**Parsers** all share one signature:
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
def parser(value, rule, ctx) -> parsed_value | None
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
`value` is the source property value (`None` for synthesized fields), `rule` is
|
|
140
|
+
the compiled row (the CSV columns), and `ctx` carries conversion state (the
|
|
141
|
+
current node, the `@id`→node index, the guid map, the root node, and any
|
|
142
|
+
per-plugin extras). Returning `None` means "set nothing." Parsers are registered
|
|
143
|
+
by name in the plugin class's `import_parsers` / `export_parsers` dicts, so the
|
|
144
|
+
loader can reject a typo in a CSV at load time. Shared building blocks live in
|
|
145
|
+
`core/parsers.py`.
|
|
146
|
+
|
|
147
|
+
**Plugin methods** are the per-workflow orchestration — override them on your
|
|
148
|
+
`PluginBase` subclass (full list + when to override each: `docs/NEW-PLUGIN.md`):
|
|
149
|
+
|
|
150
|
+
| method | what it does | examples |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `load_source` | turn the raw input into `SourceRecord`s (the common override) | example; helpers `records_from_items` / `records_from_graph` |
|
|
153
|
+
| `classify` / `discriminators` | match a record to its `entities.csv` row, gated by named predicates | wrroc workflow-vs-tool discriminator |
|
|
154
|
+
| `pre` | full-control record production: build indexes, mint ARKs, load join/CV tables | c2m2 guid-map + association index; wrroc id minting |
|
|
155
|
+
| `map_record` | **engine default** applies the property rules; override only when a record doesn't map rule-by-rule | d4d many-to-one merge; c2m2 vars pipeline |
|
|
156
|
+
| `link` | cross-node edges added after all nodes exist | wrroc `isPartOf` / `generatedBy` mirroring |
|
|
157
|
+
| `assemble` | build the root, attach children, write preservation files | c2m2 root + datapackage/sqlite copy |
|
|
158
|
+
| `export` | bespoke reverse driver or engine bypass | wrroc/d4d reverse; croissant |
|
|
159
|
+
|
|
160
|
+
A plugin implements only the steps it needs; the rest default to the engine's
|
|
161
|
+
generic behaviour. Property-rule application is the engine's own code unless a
|
|
162
|
+
plugin genuinely cannot map rule-by-rule — which is the whole point of the
|
|
163
|
+
format.
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
## Worked rows (one per plugin)
|
|
168
|
+
|
|
169
|
+
**wrroc** `entities.csv` — a workflow-level run becomes an EVI Computation:
|
|
170
|
+
```
|
|
171
|
+
source_type,discriminator,target_type,target_type_iri,level,id_strategy,id_template,id_column,keep_identifier,mint_prefix,note
|
|
172
|
+
CreateAction,instrument_is_workflow,Computation,prov:Activity|https://w3id.org/EVI#Computation,workflow,mint,,,TRUE,computation-main,parent run
|
|
173
|
+
```
|
|
174
|
+
wrroc `properties.csv` — description synthesized to satisfy EVI's min-length:
|
|
175
|
+
```
|
|
176
|
+
source_type,source_property,target_type,target_property,import_parser,export_parser,direction,reverse_primary,requirement,cardinality,constant_value,fallback_source,fallback_parser,source_table,param_name,wrap,note
|
|
177
|
+
CreateAction,description,Computation,description,desc_min10,scalar,both,TRUE,synthesized,scalar,,,,,,,
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**d4d** `entities.csv` — a distribution becomes a DigitalObject graph node
|
|
181
|
+
(d4d reuses `target_type_iri` for the additionalType and `id_column` for the
|
|
182
|
+
url field):
|
|
183
|
+
```
|
|
184
|
+
distributions,,DigitalObject,distribution,data,keep,,download_url,FALSE,,each distribution -> DigitalObject in hasPart
|
|
185
|
+
```
|
|
186
|
+
d4d `properties.csv` — one of the many sources merging onto `rai_data_use_cases`:
|
|
187
|
+
```
|
|
188
|
+
D4D,intended_uses,Dataset,rai_data_use_cases,flatten,reverse_flatten,both,TRUE,recommended,list,,,,,,,
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
**c2m2** `entities.csv` — the biosample table explodes to EVI Samples (c2m2's
|
|
192
|
+
file adds a `precedence` column before `note`; d4d/wrroc's files don't have it):
|
|
193
|
+
```
|
|
194
|
+
biosample,,Sample,,data,persistent_ark_or_mint,{prefix}-biosample/{local_id},persistent_id,FALSE,,20,
|
|
195
|
+
```
|
|
196
|
+
c2m2 `associations.csv` — observed disease edges only:
|
|
197
|
+
```
|
|
198
|
+
source_type,assoc_table,match_columns,match_resolver,value_columns,value_resolver,value_entity,target_property,wrap,filter_column,filter_endswith,param_name,note
|
|
199
|
+
biosample,biosample_disease,biosample_id_namespace|biosample_local_id,entity_key,disease,ontology_term,disease,associatedDisease,ident_ref,association_type,:1,,
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
**croissant** `entities.csv` — the root crate becomes a Croissant Dataset (export-only):
|
|
203
|
+
```
|
|
204
|
+
ROCrateMetadataElem,ROOT,CroissantDataset,,root,keep,,,FALSE,,0,the crate root -> Croissant Dataset
|
|
205
|
+
```
|
|
206
|
+
croissant `properties.csv` — encodingFormat mapped to a MIME type on export:
|
|
207
|
+
```
|
|
208
|
+
Dataset,format,CroissantFileObject,encodingFormat,,map_format_to_mime_type,export,FALSE,,scalar,,,,,,,
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
**example** `entities.csv` + `properties.csv` — the minimal plugin
|
|
212
|
+
(`plugins/example/`), a toy memo becoming a schema.org Dataset:
|
|
213
|
+
```
|
|
214
|
+
memo,,Dataset,https://schema.org/Dataset,data,keep,,,FALSE,,one Dataset per memo
|
|
215
|
+
memo,title,Dataset,name,scalar,,import,,required,scalar,,,,,,,
|
|
216
|
+
```
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# fairscape-conversion
|
|
2
|
+
|
|
3
|
+
Your metadata is already written down — as a datasheet, a datapackage, or a
|
|
4
|
+
workflow engine's run output. Convert it into a FAIRSCAPE/EVI RO-Crate
|
|
5
|
+
(`ro-crate-metadata.json`) instead of re-entering it. One command per format.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install -e . # from this directory; installs fairscape-conversion
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The `fairscape import` / `fairscape export` commands come with the companion
|
|
12
|
+
`fairscape` CLI (not yet published). Without it, every conversion also runs as
|
|
13
|
+
`python -m fairscape_conversion.core.cli convert <format> <import|export> IN [OUT]`.
|
|
14
|
+
|
|
15
|
+
## Import — get an RO-Crate
|
|
16
|
+
|
|
17
|
+
Pick the row that matches what you have.
|
|
18
|
+
|
|
19
|
+
| You have | You need | Run |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| A Datasheet for Datasets (D4D) | the datasheet as YAML or JSON | `fairscape import d4d datasheet.yaml -o ./crate` |
|
|
22
|
+
| A CFDE C2M2 datapackage | the directory of TSVs + `C2M2_datapackage.json` | `fairscape import c2m2 ./datapackage-dir -o ./crate` |
|
|
23
|
+
| A Workflow Run RO-Crate | its `ro-crate-metadata.json` | `fairscape import wrroc ro-crate-metadata.json -o ./crate` |
|
|
24
|
+
| A finished Cromwell/WDL run | the file from `cromwell run -m metadata.json` | `fairscape import cromwell metadata.json -o ./crate` |
|
|
25
|
+
| A finished Snakemake run | the records JSON from `snakemake --reporter fairscape` | `fairscape import snakemake records.json -o ./crate` |
|
|
26
|
+
| Finished MLflow runs | the tracking store (an `mlruns` dir or tracking URI) and `pip install mlflow` | `fairscape import mlflow ./mlruns --experiment NAME -o ./crate` |
|
|
27
|
+
|
|
28
|
+
## Export — from an RO-Crate
|
|
29
|
+
|
|
30
|
+
| You want | Run |
|
|
31
|
+
|---|---|
|
|
32
|
+
| A D4D datasheet | `fairscape export d4d ro-crate-metadata.json` |
|
|
33
|
+
| A Workflow Run RO-Crate | `fairscape export wrroc ro-crate-metadata.json` |
|
|
34
|
+
| An MLCommons Croissant document | `fairscape export croissant ro-crate-metadata.json` |
|
|
35
|
+
|
|
36
|
+
## Try it — no data needed
|
|
37
|
+
|
|
38
|
+
Every format ships a real example input and its expected output inside its
|
|
39
|
+
plugin folder, so you can run any conversion right now:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
fairscape import d4d plugins/d4d/input.yaml -o /tmp/crate
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
| format | example input | expected output |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| d4d | `plugins/d4d/input.yaml` (the AI-READI datasheet) | `plugins/d4d/golden.json` |
|
|
48
|
+
| c2m2 | `plugins/c2m2/input-datapackage/` (miniature datapackage) | `plugins/c2m2/golden.json` |
|
|
49
|
+
| wrroc | `plugins/wrroc/input.json` (CWL revsort run crate) | `plugins/wrroc/golden.json` |
|
|
50
|
+
| cromwell | `plugins/cromwell/input.json` (scatter workflow records) | `plugins/cromwell/golden.json` |
|
|
51
|
+
| snakemake | `plugins/snakemake/input.json` (3-rule chain records) | `plugins/snakemake/golden.json` |
|
|
52
|
+
| mlflow | `plugins/mlflow/input.json` (iris experiment records) | `plugins/mlflow/golden.json` |
|
|
53
|
+
| croissant | `plugins/croissant/input.json` (export this crate) | `plugins/croissant/golden.json` |
|
|
54
|
+
|
|
55
|
+
## From Python
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
import yaml
|
|
59
|
+
from fairscape_conversion.plugins import d4d
|
|
60
|
+
|
|
61
|
+
crate = d4d.convert("import", yaml.safe_load(open("datasheet.yaml")))
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Same shape for every format: `wrroc`, `c2m2`, `cromwell`, `snakemake`,
|
|
65
|
+
`mlflow` (`convert("import", ...)`), and `d4d`/`wrroc`/`croissant`
|
|
66
|
+
(`convert("export", crate)`).
|
|
67
|
+
|
|
68
|
+
## More
|
|
69
|
+
|
|
70
|
+
- Your format isn't listed → [`docs/NEW-PLUGIN.md`](docs/NEW-PLUGIN.md) —
|
|
71
|
+
a converter is a folder of two CSVs plus a small plugin class.
|
|
72
|
+
- How the engine works, what's tested → [`docs/INTERNALS.md`](docs/INTERNALS.md)
|
|
73
|
+
and [`MAPPING-SCHEMA.md`](MAPPING-SCHEMA.md).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Unified fairscape RO-Crate converters — one CSV mapping format for all."""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unified converter core — one mapping format, one kernel, per-plugin code.
|
|
3
|
+
|
|
4
|
+
Public API used by the plugins:
|
|
5
|
+
|
|
6
|
+
from fairscape_conversion.core import (Plugin, Context, Record, Mapping,
|
|
7
|
+
run_pipeline, apply_import_rules, apply_export_rules,
|
|
8
|
+
run_import_parser, run_export_parser,
|
|
9
|
+
interpolate, roundtrip)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from . import parsers, roundtrip
|
|
13
|
+
from .engine import (Context, Record, apply_export_rules, apply_import_rules,
|
|
14
|
+
interpolate, run_export_parser, run_import_parser,
|
|
15
|
+
run_pipeline)
|
|
16
|
+
from .loader import load_mapping
|
|
17
|
+
from .plugin import PluginBase
|
|
18
|
+
from .records import (SourceRecord, classify_node, records_from_graph,
|
|
19
|
+
records_from_items, types_of)
|
|
20
|
+
from .registry import Plugin
|
|
21
|
+
from .schema import (AssocRule, CvBase, EntityRule, Mapping, PropertyRule,
|
|
22
|
+
split_pipe)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"Plugin", "PluginBase", "Context", "Record", "Mapping",
|
|
26
|
+
"SourceRecord", "classify_node", "records_from_items", "records_from_graph",
|
|
27
|
+
"types_of",
|
|
28
|
+
"EntityRule", "PropertyRule", "AssocRule", "CvBase", "split_pipe",
|
|
29
|
+
"load_mapping", "run_pipeline",
|
|
30
|
+
"apply_import_rules", "apply_export_rules",
|
|
31
|
+
"run_import_parser", "run_export_parser",
|
|
32
|
+
"interpolate", "parsers", "roundtrip",
|
|
33
|
+
]
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Deterministic ARK minting — the fairscape identifier scheme, in core.
|
|
2
|
+
|
|
3
|
+
``ark:{naan}/{prefix}-{slug(name)}-{sha1(source)[:7]}``: the slug is the
|
|
4
|
+
readable half, the hash names the thing (a stable source string — a file path,
|
|
5
|
+
a rule name + outputs, a session id), so re-running a conversion over the same
|
|
6
|
+
source reproduces the same identifiers. This is the scheme nf-fairscape and
|
|
7
|
+
snakemake-report-plugin-fairscape mint, and the one the wrroc plugin composes
|
|
8
|
+
(with a ``wrroc-`` tag folded into the prefix).
|
|
9
|
+
|
|
10
|
+
Plugins that need a namespace tag put it in ``prefix`` (``wrroc-computation``);
|
|
11
|
+
the minter itself adds nothing.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import hashlib
|
|
15
|
+
import re
|
|
16
|
+
|
|
17
|
+
DEFAULT_NAAN = "59853"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def slugify(text, max_len=40, fallback="entity"):
|
|
21
|
+
slug = re.sub(r"[^a-z0-9]+", "-", str(text).lower()).strip("-")
|
|
22
|
+
return slug[:max_len].strip("-") or fallback
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def short_hash(text, length=7):
|
|
26
|
+
return hashlib.sha1(str(text).encode()).hexdigest()[:length]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def mint_ark(naan, prefix, name, source, fallback="entity"):
|
|
30
|
+
return f"ark:{naan}/{prefix}-{slugify(name, fallback=fallback)}-{short_hash(source)}"
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Tiny CLI: ``fairscape_conversion convert <plugin> <import|export> <input> [output]``.
|
|
3
|
+
|
|
4
|
+
Reads JSON or YAML by extension, dispatches to the named plugin's ``convert``,
|
|
5
|
+
writes JSON (or YAML for a ``.yaml``/``.yml`` output path). The library API
|
|
6
|
+
(``fairscape_conversion.plugins.<name>.convert``) is what the tests and other code call; this
|
|
7
|
+
is only a convenience wrapper.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import importlib
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _read(path: Path):
|
|
19
|
+
text = path.read_text()
|
|
20
|
+
if path.suffix in (".yaml", ".yml"):
|
|
21
|
+
import yaml
|
|
22
|
+
return yaml.safe_load(text)
|
|
23
|
+
return json.loads(text)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write(path: Path, data):
|
|
27
|
+
if path.suffix in (".yaml", ".yml"):
|
|
28
|
+
import yaml
|
|
29
|
+
path.write_text(yaml.dump(data, sort_keys=False, allow_unicode=True))
|
|
30
|
+
else:
|
|
31
|
+
path.write_text(json.dumps(data, indent=2, default=str))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main(argv=None):
|
|
35
|
+
argv = argv if argv is not None else sys.argv[1:]
|
|
36
|
+
if len(argv) < 4 or argv[0] != "convert":
|
|
37
|
+
print(__doc__)
|
|
38
|
+
return 1
|
|
39
|
+
_, plugin_name, direction, in_path, *rest = argv
|
|
40
|
+
if direction not in ("import", "export"):
|
|
41
|
+
print(f"direction must be import|export, got {direction!r}")
|
|
42
|
+
return 1
|
|
43
|
+
plugin = importlib.import_module(f"fairscape_conversion.plugins.{plugin_name}")
|
|
44
|
+
result = plugin.convert(direction, _read(Path(in_path)))
|
|
45
|
+
if rest:
|
|
46
|
+
_write(Path(rest[0]), result)
|
|
47
|
+
print(f"wrote {rest[0]}")
|
|
48
|
+
else:
|
|
49
|
+
print(json.dumps(result, indent=2, default=str))
|
|
50
|
+
return 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
if __name__ == "__main__":
|
|
54
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The shared conversion kernel.
|
|
3
|
+
|
|
4
|
+
Everything workflow-agnostic lives here: applying property rules with named
|
|
5
|
+
parsers, interpolating id/constant templates, and a light ``run_pipeline``
|
|
6
|
+
driver that owns the ``map`` stage (per-record via the overridable
|
|
7
|
+
``map_record`` hook) and calls the plugin's ``pre`` / ``link`` / ``assemble``
|
|
8
|
+
hooks around it.
|
|
9
|
+
|
|
10
|
+
The kernel is deliberately small. A plugin that does not fit the pipeline (d4d's
|
|
11
|
+
many-to-one root merge) still uses the same ``run_parser`` + rule objects — it
|
|
12
|
+
just composes them itself. That is the unification: one mapping format, one
|
|
13
|
+
parser signature, one kernel, and per-workflow orchestration where it is
|
|
14
|
+
genuinely different.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
|
|
22
|
+
from .schema import EntityRule, Mapping, PropertyRule
|
|
23
|
+
|
|
24
|
+
_EMPTY = (None, "", [], {})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class Record:
|
|
29
|
+
"""A source record queued for mapping: its data + the entity rule matched."""
|
|
30
|
+
|
|
31
|
+
data: dict
|
|
32
|
+
rule: EntityRule
|
|
33
|
+
source_id: str = ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Context:
|
|
37
|
+
"""Mutable conversion state passed to every parser and hook.
|
|
38
|
+
|
|
39
|
+
``extras`` is the plugin scratchpad (guid maps, fallback agents, association
|
|
40
|
+
indexes, …). ``node`` / ``node_extras`` are set by the engine per record so
|
|
41
|
+
parsers can reach the record being mapped and stash side outputs.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(self, plugin, direction: str, source):
|
|
45
|
+
self.plugin = plugin
|
|
46
|
+
self.mapping: Mapping = plugin.mapping
|
|
47
|
+
self.direction = direction # "import" | "export"
|
|
48
|
+
self.source = source
|
|
49
|
+
self.by_id: dict = {}
|
|
50
|
+
self.root: dict = {}
|
|
51
|
+
self.records: list[Record] = []
|
|
52
|
+
self.out_nodes: dict = {}
|
|
53
|
+
self.order: list = []
|
|
54
|
+
self.extras: dict = {}
|
|
55
|
+
self.node: dict = {}
|
|
56
|
+
self.node_extras: dict = {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
# ============================================================================
|
|
60
|
+
# Templates
|
|
61
|
+
# ============================================================================
|
|
62
|
+
|
|
63
|
+
_TOKEN = re.compile(r"\{([^{}]+)\}")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def interpolate(template: str, tokens: dict) -> str:
|
|
67
|
+
"""Replace ``{token}`` with ``tokens[token]`` (unknown tokens left as-is)."""
|
|
68
|
+
def sub(m):
|
|
69
|
+
key = m.group(1)
|
|
70
|
+
return str(tokens.get(key, m.group(0)))
|
|
71
|
+
return _TOKEN.sub(sub, template)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
# ============================================================================
|
|
75
|
+
# Parser application
|
|
76
|
+
# ============================================================================
|
|
77
|
+
|
|
78
|
+
def run_import_parser(ctx: Context, rule: PropertyRule, value):
|
|
79
|
+
parser = ctx.plugin.import_parsers[rule.import_parser]
|
|
80
|
+
return parser(value, rule, ctx)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run_export_parser(ctx: Context, rule: PropertyRule, value):
|
|
84
|
+
parser = ctx.plugin.export_parsers[rule.export_parser]
|
|
85
|
+
return parser(value, rule, ctx)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _read_source(record: dict, rule: PropertyRule):
|
|
89
|
+
if rule.source_property:
|
|
90
|
+
return record.get(rule.source_property)
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def apply_import_rules(record: dict, rules: list[PropertyRule], ctx: Context) -> dict:
|
|
95
|
+
"""Apply import rules to one source record. First rule to set a target wins.
|
|
96
|
+
|
|
97
|
+
A rule whose value is empty falls back to ``fallback_source`` (+
|
|
98
|
+
``fallback_parser``) when given. ``constant_value`` is interpolated against
|
|
99
|
+
the record. Side outputs a parser stashes in ``ctx.node_extras`` (e.g.
|
|
100
|
+
``parameters``) are merged in at the end. This is the wrroc / c2m2 /
|
|
101
|
+
croissant semantics; d4d composes rules itself (many-to-one merge).
|
|
102
|
+
"""
|
|
103
|
+
out: dict = {}
|
|
104
|
+
ctx.node = record
|
|
105
|
+
ctx.node_extras = {}
|
|
106
|
+
for rule in rules:
|
|
107
|
+
target = rule.target_property
|
|
108
|
+
if out.get(target) not in _EMPTY:
|
|
109
|
+
continue
|
|
110
|
+
if rule.source_property is None and rule.constant_value:
|
|
111
|
+
result = interpolate(rule.constant_value, record)
|
|
112
|
+
else:
|
|
113
|
+
result = run_import_parser(ctx, rule, _read_source(record, rule))
|
|
114
|
+
if result is None and rule.fallback_source:
|
|
115
|
+
fb_value = record.get(rule.fallback_source)
|
|
116
|
+
result = ctx.plugin.import_parsers[
|
|
117
|
+
rule.fallback_parser or rule.import_parser](fb_value, rule, ctx)
|
|
118
|
+
if result is not None:
|
|
119
|
+
out[target] = result
|
|
120
|
+
for k, v in ctx.node_extras.items():
|
|
121
|
+
out.setdefault(k, v)
|
|
122
|
+
return out
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def apply_export_rules(node: dict, rules: list[PropertyRule], ctx: Context) -> dict:
|
|
126
|
+
"""Apply export rules to one target-side node. First rule to set wins."""
|
|
127
|
+
out: dict = {}
|
|
128
|
+
ctx.node = node
|
|
129
|
+
for rule in rules:
|
|
130
|
+
target = rule.source_property
|
|
131
|
+
if not target or out.get(target) not in _EMPTY:
|
|
132
|
+
continue
|
|
133
|
+
result = run_export_parser(ctx, rule, node.get(rule.target_property))
|
|
134
|
+
if result is not None:
|
|
135
|
+
out[target] = result
|
|
136
|
+
return out
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ============================================================================
|
|
140
|
+
# Pipeline driver (wrroc / c2m2 / croissant)
|
|
141
|
+
# ============================================================================
|
|
142
|
+
|
|
143
|
+
def _default_select_rules(ctx: Context, rec: Record) -> list[PropertyRule]:
|
|
144
|
+
"""Default rule set for a record: grouped by the fairscape-side entity.
|
|
145
|
+
|
|
146
|
+
Import: every rule producing this record's target type (several source types
|
|
147
|
+
can share one target's rule set — wrroc Software). Export: every rule reading
|
|
148
|
+
this record's kind. A plugin whose rules key on the source instead overrides
|
|
149
|
+
via ``hooks['select_rules']`` (e.g. by-table selection).
|
|
150
|
+
"""
|
|
151
|
+
if ctx.direction == "import":
|
|
152
|
+
return ctx.mapping.import_rules_by_target(rec.rule.target_type)
|
|
153
|
+
return ctx.mapping.export_rules_by_target(rec.rule.target_type)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _default_resolve_id(ctx: Context, rec: Record) -> str:
|
|
157
|
+
"""Default ``@id`` for a mapped node.
|
|
158
|
+
|
|
159
|
+
Honours a precomputed ``ctx.extras['guid_map']`` (wrroc mints ARKs up front);
|
|
160
|
+
otherwise applies the entity rule's ``id_strategy`` for the simple cases.
|
|
161
|
+
"""
|
|
162
|
+
guid_map = ctx.extras.get("guid_map")
|
|
163
|
+
if guid_map and rec.source_id in guid_map:
|
|
164
|
+
return guid_map[rec.source_id]
|
|
165
|
+
rule = rec.rule
|
|
166
|
+
if rule.id_strategy in ("keep", "column"):
|
|
167
|
+
return rec.source_id
|
|
168
|
+
if rule.id_template:
|
|
169
|
+
return interpolate(rule.id_template, rec.data)
|
|
170
|
+
return rec.source_id
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _default_map_record(ctx: Context, rec: Record, rules: list[PropertyRule]):
|
|
174
|
+
"""Default per-record mapper: apply the CSV property rules, stamp ``@type``
|
|
175
|
+
and the round-trip ``identifier``.
|
|
176
|
+
|
|
177
|
+
A plugin overrides this (``hooks['map_record']``) when one record does not
|
|
178
|
+
map rule-by-rule — c2m2's vars pipeline, d4d's many-to-one merge. An
|
|
179
|
+
override may set ``@id`` itself and may return ``None`` to drop the record.
|
|
180
|
+
"""
|
|
181
|
+
if ctx.direction == "import":
|
|
182
|
+
node = apply_import_rules(rec.data, rules, ctx)
|
|
183
|
+
else:
|
|
184
|
+
node = apply_export_rules(rec.data, rules, ctx)
|
|
185
|
+
if rec.rule.target_type_iri:
|
|
186
|
+
node["@type"] = (rec.rule.target_type_iri if len(rec.rule.target_type_iri) > 1
|
|
187
|
+
else rec.rule.target_type_iri[0])
|
|
188
|
+
if ctx.direction == "import" and rec.rule.keep_identifier and rec.source_id:
|
|
189
|
+
node["identifier"] = rec.source_id
|
|
190
|
+
return node
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def run_pipeline(plugin, direction: str, source, options: dict | None = None):
|
|
194
|
+
"""Drive a pipeline-style conversion: pre -> map (engine) -> link -> assemble.
|
|
195
|
+
|
|
196
|
+
The plugin's ``pre`` hook fills ``ctx.records`` (and any indexes in
|
|
197
|
+
``ctx.extras``); the engine maps each record with its rule set via
|
|
198
|
+
``map_record``; ``link`` adds cross-node edges; ``assemble`` builds and
|
|
199
|
+
returns the final object. Per-run options (e.g. ``naan``, ``validate``) are
|
|
200
|
+
seeded into ``ctx.extras``.
|
|
201
|
+
|
|
202
|
+
A record whose mapping returns ``None`` is dropped. On a duplicate ``@id``
|
|
203
|
+
the first-mapped node wins (later records are skipped) — ``pre`` may seed
|
|
204
|
+
``ctx.out_nodes``/``ctx.order`` to reserve ids ahead of the loop.
|
|
205
|
+
"""
|
|
206
|
+
ctx = Context(plugin, direction, source)
|
|
207
|
+
if options:
|
|
208
|
+
ctx.extras.update(options)
|
|
209
|
+
plugin.hook("pre")(ctx)
|
|
210
|
+
|
|
211
|
+
select_rules = plugin.hooks.get("select_rules", _default_select_rules)
|
|
212
|
+
resolve_id = plugin.hooks.get("resolve_id", _default_resolve_id)
|
|
213
|
+
map_record = plugin.hooks.get("map_record", _default_map_record)
|
|
214
|
+
|
|
215
|
+
for rec in ctx.records:
|
|
216
|
+
node = map_record(ctx, rec, select_rules(ctx, rec))
|
|
217
|
+
if node is None:
|
|
218
|
+
continue
|
|
219
|
+
node.setdefault("@id", resolve_id(ctx, rec))
|
|
220
|
+
if node["@id"] in ctx.out_nodes: # first writer wins
|
|
221
|
+
continue
|
|
222
|
+
ctx.out_nodes[node["@id"]] = node
|
|
223
|
+
ctx.order.append(node["@id"])
|
|
224
|
+
|
|
225
|
+
plugin.hook("link")(ctx)
|
|
226
|
+
return plugin.hook("assemble")(ctx)
|