API reference

CWL to OGC API - Records Transpiler-Mate Plugin.

Plugin registration

CWL to OGC API - Records Transpiler-Mate Plugin.

Source code in src/cwl2ogcrecords/plugin.py
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
@transpiler_plugin(
    name="cwl2ogcrecords",
    description="CWL to OGC API - Records Transpiler-Mate Plugin.",
    options_model=CWL2OGCAPIRecordsOptions,
)
def cwl2ogcrecords(
    context: TranspilerContext, options: CWL2OGCAPIRecordsOptions
) -> None:
    """CWL to OGC API - Records Transpiler-Mate Plugin."""
    logger.info("Converting input CWL to OGC API - Records...")

    record: OGCRecord = OGCRecord(
        id=context.process_id if context.process_id else f"urn:uuid:{uuid.uuid4()}",
    )
    record.created = _to_datetime(context.metadata.date_created)
    record.updated = _to_datetime(datetime.now())
    record.title = context.metadata.name
    record.description = (
        context.metadata.description if context.metadata.description else None
    )
    record.language = __DEFAULT_LANGUAGE__
    record.resource_languages = [__DEFAULT_LANGUAGE__]

    record.license = ": ".join(
        [
            (
                str(license.identifier)
                if isinstance(license, CreativeWork)
                else str(license)
            )
            for license in (
                context.metadata.license
                if isinstance(context.metadata.license, list)
                else [context.metadata.license]
            )
        ]
    )

    record.contacts = list(
        map(
            _to_contact,
            context.metadata.author
            if isinstance(context.metadata.author, list)
            else [context.metadata.author],
        )
    )

    _add_kw_themes(context.metadata, record)

    _add_help_links(context.metadata, record)

    logger.success("Input CWL successfully converted to OGC API - Records!")

    try:
        options.output.parent.mkdir(parents=True, exist_ok=True)
        logger.info(f"Serializing CodeMeta metadata to {options.output.absolute()}")

        with options.output.open("w") as output_stream:
            json.dump(
                record.to_dict(include_self_link=False),
                output_stream,
                indent=2,
                )

        logger.success(
            f"CodeMeta metadata successfully serialized to {options.output.absolute()}"
        )
    except Exception as e:
        raise PluginExecutionError(
            f"An error occurred when serializing to {options.output.absolute()}, see nested exception"
        ) from e

Options

Bases: BaseModel

Options accepted by the CWL to OGC API - Records plugin.

Source code in src/cwl2ogcrecords/plugin.py
55
56
57
58
59
60
61
62
63
class CWL2OGCAPIRecordsOptions(BaseModel):
    """Options accepted by the CWL to OGC API - Records plugin."""

    model_config = ConfigDict(extra="forbid")

    output: Annotated[
        Path,
        Field(default=Path("ogc-record.json"), description="The output file path"),
    ]

Record model

Bases: RecordMetadataMixin, Item

Item-compatible Record, with OGC serialization as the default.

Initializes STACObject directly to avoid Item's mandatory temporal extent. This compatibility seam must be reviewed for future PySTAC releases. time and STAC datetime fields are independent: no implicit conversion.

Source code in src/cwl2ogcrecords/ogc_record.py
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
class OGCRecord(RecordMetadataMixin, pystac.Item):
    """Item-compatible Record, with OGC serialization as the default.

    Initializes STACObject directly to avoid Item's mandatory temporal extent.
    This compatibility seam must be reviewed for future PySTAC releases.
    `time` and STAC datetime fields are independent: no implicit conversion.
    """

    SCHEMA_URI = (
        "https://schemas.opengis.net/ogcapi/records/part1/1.0/"
        "openapi/schemas/recordGeoJSON.yaml"
    )
    _RESERVED = {
        "id",
        "type",
        "geometry",
        "properties",
        "links",
        "assets",
        "bbox",
        "collection",
        "stac_extensions",
    }

    def __init__(  # noqa: C901
        self,
        id: str | int,
        geometry: dict[str, Any] | None = None,
        bbox: list[float] | None = None,
        datetime: Datetime | None = None,
        properties: dict[str, Any] | None = None,
        start_datetime: Datetime | None = None,
        end_datetime: Datetime | None = None,
        stac_extensions: list[str] | None = None,
        href: str | None = None,
        collection: str | pystac.Collection | None = None,
        extra_fields: dict[str, Any] | None = None,
        assets: dict[str, pystac.Asset] | None = None,
        *,
        time: dict[str, Any] | None = None,
        conforms_to: list[str] | None = None,
        link_templates: list[dict[str, Any]] | None = None,
    ):
        if isinstance(id, bool) or not isinstance(id, (str, int)):
            raise TypeError("Record id must be a string or integer")
        pystac.STACObject.__init__(self, list(stac_extensions or []))
        self._record_id = id
        self.id = str(id)  # PySTAC path/layout utilities require a string.
        self.geometry = deepcopy(geometry)
        self.bbox = deepcopy(bbox)
        self._null_properties = properties is None
        self.properties = deepcopy(properties) if properties is not None else {}
        self.extra_fields = deepcopy(extra_fields) if extra_fields else {}
        if self._RESERVED.intersection(self.extra_fields):
            raise ValueError("extra_fields must not override managed fields")
        self.assets = {}
        self.collection_id = None
        self._stac_io = None
        self.datetime = datetime
        if datetime is not None:
            self.properties["datetime"] = datetime_to_str(datetime)
        elif self.properties.get("datetime") is not None:
            self.datetime = str_to_datetime(self.properties["datetime"])
        if start_datetime is not None:
            self.properties["start_datetime"] = datetime_to_str(start_datetime)
        if end_datetime is not None:
            self.properties["end_datetime"] = datetime_to_str(end_datetime)
        if time is not None:
            self.time = deepcopy(time)
        if conforms_to is not None:
            self.conforms_to = list(conforms_to)
        if link_templates is not None:
            self.link_templates = deepcopy(link_templates)
        if isinstance(collection, pystac.Collection):
            self.set_collection(collection)
        elif collection is not None:
            self.collection_id = collection
        for key, asset in (assets or {}).items():
            self.add_asset(key, asset)
        if href is not None:
            self.set_self_href(href)

    @property
    def record_id(self) -> str | int:
        return self._record_id if str(self._record_id) == self.id else self.id

    @property
    def record_metadata(self) -> RecordCommonProperties:
        return RecordCommonProperties(self)

    @property
    def time(self) -> dict[str, Any] | None:
        return self.extra_fields.get("time")

    @time.setter
    def time(self, value: dict[str, Any] | None) -> None:
        self.extra_fields["time"] = value

    @property
    def conforms_to(self) -> list[str]:
        return self.extra_fields.setdefault("conformsTo", [])

    @conforms_to.setter
    def conforms_to(self, value: list[str]) -> None:
        self.extra_fields["conformsTo"] = value

    @property
    def link_templates(self) -> list[dict[str, Any]]:
        return self.extra_fields.setdefault("linkTemplates", [])

    @link_templates.setter
    def link_templates(self, value: list[dict[str, Any]]) -> None:
        self.extra_fields["linkTemplates"] = value

    def to_dict(
        self, include_self_link: bool = True, transform_hrefs: bool = True
    ) -> dict[str, Any]:
        props = deepcopy(self.properties)
        if self.datetime is not None:
            props["datetime"] = datetime_to_str(self.datetime)
        doc = deepcopy(self.extra_fields)
        doc.update(
            type="Feature",
            id=self.record_id,
            geometry=deepcopy(self.geometry),
            properties=None if self._null_properties and not props else props,
            links=[
                link.to_dict(transform_href=transform_hrefs)
                for link in self.links
                if include_self_link or link.rel != pystac.RelType.SELF
            ],
        )
        if self.bbox is not None:
            doc["bbox"] = list(self.bbox)
        # Preserve extension declarations/assets as GeoJSON foreign members.
        # Never substitute conformsTo for stac_extensions.
        if self.stac_extensions:
            doc["stac_extensions"] = list(self.stac_extensions)
        if self.assets:
            doc["assets"] = {
                key: deepcopy(asset.to_dict()) for key, asset in self.assets.items()
            }
        if self.collection_id is not None:
            doc["collection"] = self.collection_id
        return doc

    def to_record_dict(
        self, include_self_link: bool = True, transform_hrefs: bool = True
    ) -> dict[str, Any]:
        return self.to_dict(include_self_link, transform_hrefs)

    @classmethod
    def matches_object_type(cls, d: dict[str, Any]) -> bool:
        # Structural recognition only; GeoJSON/STAC overlap prevents unique dispatch.
        return (
            d.get("type") == "Feature"
            and all(key in d for key in ("id", "geometry", "properties"))
            and isinstance(d["id"], (str, int))
            and not isinstance(d["id"], bool)
            and (d["properties"] is None or isinstance(d["properties"], dict))
        )

    @classmethod
    def from_dict(
        cls,
        d: dict[str, Any],
        href: str | None = None,
        root: pystac.Catalog | None = None,
        migrate: bool = True,
        preserve_dict: bool = True,
    ) -> OGCRecord:
        """Read Records; migrate is accepted for compatibility but never applied.

        Always copies document metadata, even when preserve_dict=False.
        """
        if not cls.matches_object_type(d):
            raise ValueError(
                "Expected a Record GeoJSON Feature with id, geometry, properties"
            )
        obj = cls(
            id=d["id"],
            geometry=d["geometry"],
            properties=d["properties"],
            bbox=d.get("bbox"),
            collection=d.get("collection"),
            stac_extensions=d.get("stac_extensions"),
            extra_fields={k: v for k, v in d.items() if k not in cls._RESERVED},
            assets={
                k: pystac.Asset.from_dict(deepcopy(v))
                for k, v in d.get("assets", {}).items()
            },
        )
        for link in d.get("links", []):
            if href is None or link.get("rel") != "self":
                obj.add_link(pystac.Link.from_dict(deepcopy(link)))
        if href is not None:
            obj.set_self_href(href)
        if root is not None:
            obj.set_root(root)
        return obj

    def clone(self) -> OGCRecord:
        doc = self.to_dict(transform_hrefs=False)
        doc["links"] = []
        result = type(self).from_dict(doc)
        for link in self.links:
            result.add_link(link.clone())
        result._stac_io = self._stac_io
        return result

    def to_stac_item(self) -> pystac.Item:
        """Explicit export; requires a genuine STAC temporal extent.

        Constructing an Item does not replace full STAC schema validation.
        """
        if self.datetime is None and not all(
            self.properties.get(k) is not None
            for k in ("start_datetime", "end_datetime")
        ):
            raise ValueError(
                "STAC export requires datetime or start_datetime/end_datetime"
            )
        extra = deepcopy(self.extra_fields)
        extra.pop("stac_version", None)
        item = pystac.Item(
            id=self.id,
            geometry=deepcopy(self.geometry),
            bbox=deepcopy(self.bbox),
            datetime=self.datetime,
            properties=deepcopy(self.properties),
            stac_extensions=list(self.stac_extensions),
            collection=self.collection_id,
            extra_fields=extra,
            assets={k: v.clone() for k, v in self.assets.items()},
        )
        for link in self.links:
            item.add_link(link.clone())
        return item

    def validate(self, validator: Any = None) -> list[Any]:
        """Validate the OGC document with an explicitly supplied validator.

        Supply an object exposing validate(document), configured for the OGC
        OpenAPI 3.0 schema and its references. No implicit STAC validation.
        """
        if validator is None:
            raise ValueError(
                "Supply an OGC schema validator; for STAC use "
                "record.to_stac_item().validate()"
            )
        validator.validate(self.to_dict(transform_hrefs=False))
        return [self.SCHEMA_URI]

    def __repr__(self) -> str:
        return f"<OGCRecord id={self.record_id!r}>"

from_dict(d, href=None, root=None, migrate=True, preserve_dict=True) classmethod

Read Records; migrate is accepted for compatibility but never applied.

Always copies document metadata, even when preserve_dict=False.

Source code in src/cwl2ogcrecords/ogc_record.py
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
@classmethod
def from_dict(
    cls,
    d: dict[str, Any],
    href: str | None = None,
    root: pystac.Catalog | None = None,
    migrate: bool = True,
    preserve_dict: bool = True,
) -> OGCRecord:
    """Read Records; migrate is accepted for compatibility but never applied.

    Always copies document metadata, even when preserve_dict=False.
    """
    if not cls.matches_object_type(d):
        raise ValueError(
            "Expected a Record GeoJSON Feature with id, geometry, properties"
        )
    obj = cls(
        id=d["id"],
        geometry=d["geometry"],
        properties=d["properties"],
        bbox=d.get("bbox"),
        collection=d.get("collection"),
        stac_extensions=d.get("stac_extensions"),
        extra_fields={k: v for k, v in d.items() if k not in cls._RESERVED},
        assets={
            k: pystac.Asset.from_dict(deepcopy(v))
            for k, v in d.get("assets", {}).items()
        },
    )
    for link in d.get("links", []):
        if href is None or link.get("rel") != "self":
            obj.add_link(pystac.Link.from_dict(deepcopy(link)))
    if href is not None:
        obj.set_self_href(href)
    if root is not None:
        obj.set_root(root)
    return obj

to_stac_item()

Explicit export; requires a genuine STAC temporal extent.

Constructing an Item does not replace full STAC schema validation.

Source code in src/cwl2ogcrecords/ogc_record.py
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
def to_stac_item(self) -> pystac.Item:
    """Explicit export; requires a genuine STAC temporal extent.

    Constructing an Item does not replace full STAC schema validation.
    """
    if self.datetime is None and not all(
        self.properties.get(k) is not None
        for k in ("start_datetime", "end_datetime")
    ):
        raise ValueError(
            "STAC export requires datetime or start_datetime/end_datetime"
        )
    extra = deepcopy(self.extra_fields)
    extra.pop("stac_version", None)
    item = pystac.Item(
        id=self.id,
        geometry=deepcopy(self.geometry),
        bbox=deepcopy(self.bbox),
        datetime=self.datetime,
        properties=deepcopy(self.properties),
        stac_extensions=list(self.stac_extensions),
        collection=self.collection_id,
        extra_fields=extra,
        assets={k: v.clone() for k, v in self.assets.items()},
    )
    for link in self.links:
        item.add_link(link.clone())
    return item

validate(validator=None)

Validate the OGC document with an explicitly supplied validator.

Supply an object exposing validate(document), configured for the OGC OpenAPI 3.0 schema and its references. No implicit STAC validation.

Source code in src/cwl2ogcrecords/ogc_record.py
512
513
514
515
516
517
518
519
520
521
522
523
524
def validate(self, validator: Any = None) -> list[Any]:
    """Validate the OGC document with an explicitly supplied validator.

    Supply an object exposing validate(document), configured for the OGC
    OpenAPI 3.0 schema and its references. No implicit STAC validation.
    """
    if validator is None:
        raise ValueError(
            "Supply an OGC schema validator; for STAC use "
            "record.to_stac_item().validate()"
        )
    validator.validate(self.to_dict(transform_hrefs=False))
    return [self.SCHEMA_URI]

Metadata structures

See Typed metadata for the field summary and union constructors.

OGC API Records adapter for PySTAC 1.x.x; see README for boundaries.

Language

Bases: _RequiredLanguage

Language metadata as defined by the OGC Records language.yaml schema.

code is a required RFC 5646 language tag. Optional name is the untranslated language name and must be nonempty when supplied; alternate names the language in another language, usually English. dir defaults to ltr in the schema when omitted.

This structure provides static typing, not runtime schema validation.

Theme

Bases: TypedDict

Theme metadata as defined by the OGC Records theme.yaml schema.

Both fields are required. concepts must contain at least one concept; scheme identifies the knowledge organization system, preferably by a resolvable URI. This structure provides static typing, not runtime schema validation.

ThemeConcept

Bases: _RequiredThemeConcept

A theme concept with a required identifier and optional descriptions.

url, when supplied, must be a URI.

ExternalId

Bases: _RequiredExternalId

An external identifier with a required value and optional scheme.

scheme references the authority or knowledge organization system from which the identifier was obtained, preferably by a resolvable URI. This structure provides static typing, not runtime schema validation.

NamedFormat

Bases: _RequiredFormatName

A format identified by name, optionally including its media type.

MediaTypeFormat

Bases: _RequiredFormatMediaType

A format identified by media type, optionally including its name.

NamedContact

Bases: _RequiredContactName

A contact identified by name, optionally including an organization.

OrganizationContact

Bases: _RequiredContactOrganization

A contact identified by organization, optionally including a name.

ContactDetail

Bases: _RequiredExternalId

A phone number or email address with optional roles.

Reuses the required string value structure. Phone values must match ^\+[1-9]{1}[0-9]{3,14}$; email values must use email format.

ContactAddress

Bases: TypedDict

A contact's physical address; all fields are optional.

Bases: _ContactLinkFields

An online contact link with required href and media type.

Bases: _ContactLinkFields

A contact logo with required href, media type, and icon relation.

The media type should be an image media type.