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.
Files changed (86) hide show
  1. fairscape_conversion/MAPPING-SCHEMA.md +216 -0
  2. fairscape_conversion/README.md +73 -0
  3. fairscape_conversion/__init__.py +1 -0
  4. fairscape_conversion/core/__init__.py +33 -0
  5. fairscape_conversion/core/arks.py +30 -0
  6. fairscape_conversion/core/cli.py +54 -0
  7. fairscape_conversion/core/engine.py +226 -0
  8. fairscape_conversion/core/loader.py +122 -0
  9. fairscape_conversion/core/parsers.py +54 -0
  10. fairscape_conversion/core/plugin.py +182 -0
  11. fairscape_conversion/core/records.py +99 -0
  12. fairscape_conversion/core/registry.py +55 -0
  13. fairscape_conversion/core/roundtrip.py +97 -0
  14. fairscape_conversion/core/schema.py +138 -0
  15. fairscape_conversion/plugins/__init__.py +1 -0
  16. fairscape_conversion/plugins/c2m2/__init__.py +29 -0
  17. fairscape_conversion/plugins/c2m2/associations.csv +43 -0
  18. fairscape_conversion/plugins/c2m2/computed.csv +5 -0
  19. fairscape_conversion/plugins/c2m2/constants.csv +39 -0
  20. fairscape_conversion/plugins/c2m2/cv_bases.csv +19 -0
  21. fairscape_conversion/plugins/c2m2/entities.csv +19 -0
  22. fairscape_conversion/plugins/c2m2/golden.json +1167 -0
  23. fairscape_conversion/plugins/c2m2/impl.py +547 -0
  24. fairscape_conversion/plugins/c2m2/input-datapackage/C2M2_datapackage.json +135 -0
  25. fairscape_conversion/plugins/c2m2/input-datapackage/biosample.tsv +2 -0
  26. fairscape_conversion/plugins/c2m2/input-datapackage/file.tsv +3 -0
  27. fairscape_conversion/plugins/c2m2/input-datapackage/file_describes_biosample.tsv +2 -0
  28. fairscape_conversion/plugins/c2m2/input-datapackage/file_format.tsv +2 -0
  29. fairscape_conversion/plugins/c2m2/input-datapackage/id_namespace.tsv +2 -0
  30. fairscape_conversion/plugins/c2m2/input-datapackage/project.tsv +2 -0
  31. fairscape_conversion/plugins/c2m2/input-datapackage/subject.tsv +1 -0
  32. fairscape_conversion/plugins/c2m2/mapper.py +386 -0
  33. fairscape_conversion/plugins/c2m2/ontology.py +140 -0
  34. fairscape_conversion/plugins/c2m2/parsers.py +371 -0
  35. fairscape_conversion/plugins/c2m2/properties.csv +122 -0
  36. fairscape_conversion/plugins/c2m2/root.json +108 -0
  37. fairscape_conversion/plugins/croissant/__init__.py +41 -0
  38. fairscape_conversion/plugins/croissant/entities.csv +5 -0
  39. fairscape_conversion/plugins/croissant/golden.json +611 -0
  40. fairscape_conversion/plugins/croissant/impl.py +95 -0
  41. fairscape_conversion/plugins/croissant/input.json +1167 -0
  42. fairscape_conversion/plugins/croissant/parsers.py +52 -0
  43. fairscape_conversion/plugins/croissant/properties.csv +43 -0
  44. fairscape_conversion/plugins/cromwell/__init__.py +206 -0
  45. fairscape_conversion/plugins/cromwell/entities.csv +8 -0
  46. fairscape_conversion/plugins/cromwell/extract.py +344 -0
  47. fairscape_conversion/plugins/cromwell/golden.json +549 -0
  48. fairscape_conversion/plugins/cromwell/input.json +174 -0
  49. fairscape_conversion/plugins/cromwell/parsers.py +259 -0
  50. fairscape_conversion/plugins/cromwell/properties.csv +56 -0
  51. fairscape_conversion/plugins/d4d/__init__.py +22 -0
  52. fairscape_conversion/plugins/d4d/entities.csv +5 -0
  53. fairscape_conversion/plugins/d4d/golden.json +520 -0
  54. fairscape_conversion/plugins/d4d/impl.py +327 -0
  55. fairscape_conversion/plugins/d4d/input.yaml +689 -0
  56. fairscape_conversion/plugins/d4d/parsers.py +128 -0
  57. fairscape_conversion/plugins/d4d/properties.csv +78 -0
  58. fairscape_conversion/plugins/example/__init__.py +45 -0
  59. fairscape_conversion/plugins/example/entities.csv +2 -0
  60. fairscape_conversion/plugins/example/golden.json +20 -0
  61. fairscape_conversion/plugins/example/input.json +16 -0
  62. fairscape_conversion/plugins/example/properties.csv +4 -0
  63. fairscape_conversion/plugins/mlflow/__init__.py +204 -0
  64. fairscape_conversion/plugins/mlflow/entities.csv +8 -0
  65. fairscape_conversion/plugins/mlflow/extract.py +554 -0
  66. fairscape_conversion/plugins/mlflow/golden.json +389 -0
  67. fairscape_conversion/plugins/mlflow/input.json +230 -0
  68. fairscape_conversion/plugins/mlflow/parsers.py +320 -0
  69. fairscape_conversion/plugins/mlflow/properties.csv +63 -0
  70. fairscape_conversion/plugins/snakemake/__init__.py +185 -0
  71. fairscape_conversion/plugins/snakemake/entities.csv +8 -0
  72. fairscape_conversion/plugins/snakemake/golden.json +407 -0
  73. fairscape_conversion/plugins/snakemake/input.json +130 -0
  74. fairscape_conversion/plugins/snakemake/parsers.py +263 -0
  75. fairscape_conversion/plugins/snakemake/properties.csv +56 -0
  76. fairscape_conversion/plugins/wrroc/__init__.py +34 -0
  77. fairscape_conversion/plugins/wrroc/entities.csv +19 -0
  78. fairscape_conversion/plugins/wrroc/golden.json +335 -0
  79. fairscape_conversion/plugins/wrroc/hooks.py +359 -0
  80. fairscape_conversion/plugins/wrroc/input.json +345 -0
  81. fairscape_conversion/plugins/wrroc/parsers.py +267 -0
  82. fairscape_conversion/plugins/wrroc/properties.csv +36 -0
  83. fairscape_conversion-0.1.0.dist-info/METADATA +89 -0
  84. fairscape_conversion-0.1.0.dist-info/RECORD +86 -0
  85. fairscape_conversion-0.1.0.dist-info/WHEEL +5 -0
  86. 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)