diff --git a/docs-gen/content/docs/tools/cli.md b/docs-gen/content/docs/tools/cli.md index e404d376..7eb03abf 100644 --- a/docs-gen/content/docs/tools/cli.md +++ b/docs-gen/content/docs/tools/cli.md @@ -519,6 +519,141 @@ You can call the help for usage reference: s2dm export jsonschema --help ``` +### LinkML + +This exporter translates the given GraphQL schema to [LinkML](https://linkml.io/linkml/) schema format (`.yaml`). + +#### Key Features + +- **Complete GraphQL Type Support**: Handles scalars, objects, input objects, enums, unions, interfaces, and list fields +- **Selection Query**: Use `--selection-query` to filter exported types and fields +- **Root Type Filtering**: Use `--root-type` to export one type and its transitive dependencies +- **Naming Configuration**: Use `--naming-config` to transform names before export +- **Expanded Instance Tags**: Use `--expanded-instances` to replace instance-tag arrays with singular attributes annotated with resolved instance names +- **Explicit Schema Metadata**: Requires LinkML schema identity and namespace inputs (`--id`, `--name`, `--default-prefix`, `--default-prefix-url`) + +#### Usage + +```bash +s2dm export linkml \ + --schema schema.graphql \ + --output schema.yaml \ + --id https://covesa.global/s2dm \ + --name VehicleSchema \ + --default-prefix s2dm \ + --default-prefix-url https://covesa.global/s2dm +``` + +#### Required Options + +- `--schema, -s`: GraphQL schema file, directory, or URL (repeatable) +- `--output, -o`: Output file path (`.yaml`) +- `--id, -i`: LinkML schema identifier +- `--name, -n`: LinkML schema name +- `--default-prefix`: LinkML default prefix label +- `--default-prefix-url`: Namespace URI for `--default-prefix` + +#### Example Transformation + +Consider the following GraphQL schema: + +```graphql +type Query { + vehicle: Vehicle +} + +enum FuelType { + GASOLINE + ELECTRIC +} + +type Vehicle { + id: ID! + make: String! + year: Int + fuelType: FuelType +} +``` + +The LinkML exporter produces: + +```yaml +name: VehicleSchema +id: https://covesa.global/s2dm +imports: + - linkml:types +prefixes: + linkml: https://w3id.org/linkml/ + s2dm: https://covesa.global/s2dm +default_prefix: s2dm +default_range: string +enums: + FuelType: + permissible_values: + ELECTRIC: {} + GASOLINE: {} +classes: + Vehicle: + attributes: + id: + range: string + required: true + make: + range: string + required: true + year: + range: integer + fuelType: + range: FuelType +``` + +#### Type Mappings + +GraphQL scalar types are mapped to LinkML ranges as follows: + +| GraphQL Type | LinkML Range | +| -------------- | -------------- | +| `String` | `string` | +| `Int` | `integer` | +| `Float` | `float` | +| `Boolean` | `boolean` | +| `ID` | `string` | +| `Int8` | `integer` | +| `UInt8` | `integer` | +| `Int16` | `integer` | +| `UInt16` | `integer` | +| `UInt32` | `integer` | +| `Int64` | `integer` | +| `UInt64` | `integer` | + +Additional type behavior: + +- **Enums**: Converted to LinkML enums with `permissible_values` +- **Lists**: Converted to `multivalued: true` +- **Non-null fields**: Converted to `required: true` +- **Input objects**: Exported as LinkML classes with attributes +- **Unions**: Converted to `any_of` ranges +- **Interfaces**: Converted to abstract classes; implementing types map to `is_a`/`mixins` +- **Custom scalars**: Exported as LinkML types with `base: string` + +The exporter skips GraphQL root/introspection types and intermediate expansion types. + +#### Directive Support + +S2DM directives are converted to LinkML constraints and annotations: + +- `@range(min, max)` on output/input fields -> `minimum_value`, `maximum_value` +- `@cardinality(min, max)` on output/input fields -> `minimum_cardinality`, `maximum_cardinality` +- `@noDuplicates` on list fields -> `list_elements_unique: true` +- List item non-null (`[Type!]` / `[Type!]!`) -> `annotations.s2dm_list_item_required: "true"` +- `@metadata(...)` on object/interface/input object types and output/input fields -> LinkML annotations (`s2dm_metadata_*` keys) + +You can call the help for usage reference: + +```bash +s2dm export linkml --help +``` + ### Protocol Buffers (Protobuf) This exporter translates the given GraphQL schema to [Protocol Buffers](https://protobuf.dev/) (`.proto`) format. @@ -1688,6 +1823,7 @@ Commands that support `--naming-config`: - `check constraints` - naming validation - `compose` - naming transformation - `export jsonschema` - naming transformation +- `export linkml` - naming transformation - `export protobuf` - naming transformation - `export shacl` - naming transformation - `export vspec` - naming transformation diff --git a/pyproject.toml b/pyproject.toml index 0c42d71b..e81382de 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "requests>=2.32.0", "fastapi>=0.115.0", "uvicorn[standard]>=0.30.0", + "linkml-runtime>=1.10.0", ] requires-python = ">=3.11" diff --git a/src/s2dm/api/main.py b/src/s2dm/api/main.py index d60351d2..d9174027 100644 --- a/src/s2dm/api/main.py +++ b/src/s2dm/api/main.py @@ -9,7 +9,7 @@ from s2dm import __version__, log from s2dm.api.errors import ResponseError from s2dm.api.models.base import ErrorResponse -from s2dm.api.routes import avro, filter, jsonschema, protobuf, query_validate, shacl, validate, vspec +from s2dm.api.routes import avro, filter, jsonschema, linkml, protobuf, query_validate, shacl, validate, vspec app = FastAPI( title="S2DM Export API", @@ -117,6 +117,7 @@ def health() -> dict[str, str]: app.include_router(shacl.router, prefix="/api/v1/export", tags=["export"]) app.include_router(vspec.router, prefix="/api/v1/export", tags=["export"]) app.include_router(jsonschema.router, prefix="/api/v1/export", tags=["export"]) +app.include_router(linkml.router, prefix="/api/v1/export", tags=["export"]) app.include_router(avro.router, prefix="/api/v1/export", tags=["export"]) app.include_router(protobuf.router, prefix="/api/v1/export", tags=["export"]) app.include_router(filter.router, prefix="/api/v1/schema", tags=["schema"]) diff --git a/src/s2dm/api/models/linkml.py b/src/s2dm/api/models/linkml.py new file mode 100644 index 00000000..2dc691c6 --- /dev/null +++ b/src/s2dm/api/models/linkml.py @@ -0,0 +1,24 @@ +from pydantic import Field, field_validator + +from s2dm.api.models.base import BaseExportRequest +from s2dm.tools.validators import validate_linkml_uri_value + + +class LinkmlExportRequest(BaseExportRequest): + """Request model for LinkML export endpoint.""" + + id: str = Field(description="LinkML schema id (required)", json_schema_extra={"x-cli-flag": "--id"}) + name: str = Field(description="LinkML schema name (required)", json_schema_extra={"x-cli-flag": "--name"}) + default_prefix: str = Field( + description="LinkML default prefix (required)", + json_schema_extra={"x-cli-flag": "--default-prefix"}, + ) + default_prefix_url: str = Field( + description="LinkML default prefix URL (required)", + json_schema_extra={"x-cli-flag": "--default-prefix-url"}, + ) + + @field_validator("id", "default_prefix_url") + @classmethod + def validate_linkml_uri_fields(cls, value: str) -> str: + return validate_linkml_uri_value(value) diff --git a/src/s2dm/api/routes/linkml.py b/src/s2dm/api/routes/linkml.py new file mode 100644 index 00000000..19f2fd17 --- /dev/null +++ b/src/s2dm/api/routes/linkml.py @@ -0,0 +1,46 @@ +"""LinkML export route.""" + +from fastapi import APIRouter + +from s2dm.api.config import COMMON_RESPONSES +from s2dm.api.models.base import ApiResponse +from s2dm.api.models.linkml import LinkmlExportRequest +from s2dm.api.services.response_service import execute_and_respond +from s2dm.api.services.schema_service import load_and_process_schema_wrapper +from s2dm.exporters.linkml import translate_to_linkml +from s2dm.exporters.utils.schema_loader import check_correct_schema + +router = APIRouter(responses=COMMON_RESPONSES) + + +@router.post( + "/linkml", + response_model=ApiResponse, + openapi_extra={"x-exporter-name": "LinkML", "x-cli-command-name": "linkml"}, +) +def export_linkml(request: LinkmlExportRequest) -> ApiResponse: + """Export GraphQL schema to LinkML.""" + + def process_request() -> list[str]: + annotated_schema, _ = load_and_process_schema_wrapper( + schemas=request.schemas, + naming_config_input=request.naming_config, + selection_query_input=request.selection_query, + root_type=request.root_type, + expanded_instances=request.expanded_instances, + ) + + schema_errors = check_correct_schema(annotated_schema.schema) + if schema_errors: + raise ValueError(f"Schema validation failed: {'; '.join(schema_errors)}") + + linkml_content = translate_to_linkml( + annotated_schema, + request.id, + request.name, + request.default_prefix, + request.default_prefix_url, + ) + return [linkml_content] + + return execute_and_respond(executor=process_request, result_format="yaml") diff --git a/src/s2dm/cli.py b/src/s2dm/cli.py index ab2c3b50..6473bb84 100644 --- a/src/s2dm/cli.py +++ b/src/s2dm/cli.py @@ -21,6 +21,7 @@ from s2dm.exporters.avro import translate_to_avro_protocol, translate_to_avro_schema from s2dm.exporters.id import IDExporter from s2dm.exporters.jsonschema import translate_to_jsonschema +from s2dm.exporters.linkml import translate_to_linkml from s2dm.exporters.protobuf import translate_to_protobuf from s2dm.exporters.shacl import translate_to_shacl from s2dm.exporters.spec_history import SpecHistoryExporter @@ -49,7 +50,7 @@ from s2dm.tools.constraint_checker import ConstraintChecker from s2dm.tools.diff_parser import DiffChange from s2dm.tools.graphql_inspector import GraphQLInspector, requires_graphql_inspector -from s2dm.tools.validators import validate_language_tag +from s2dm.tools.validators import validate_language_tag, validate_linkml_uri from s2dm.units.sync import ( UNITS_META_FILENAME, UNITS_META_VERSION_KEY, @@ -750,6 +751,65 @@ def vspec( _ = output.write_text(result) +# Export -> linkml +# ---------- +@export.command +@schema_option +@selection_query_option() +@output_option +@root_type_option +@naming_config_option +@expanded_instances_option +@click.option( + "--id", + "-i", + "schema_id", + type=str, + callback=validate_linkml_uri, + required=True, + help="LinkML schema id", +) +@click.option("--name", "-n", "schema_name", type=str, required=True, help="LinkML schema name") +@click.option("--default-prefix", type=str, required=True, help="LinkML default prefix") +@click.option( + "--default-prefix-url", + type=str, + callback=validate_linkml_uri, + required=True, + help="LinkML default prefix URL", +) +def linkml( + schemas: list[Path], + selection_query: Path | None, + output: Path, + root_type: str | None, + naming_config: Path | None, + expanded_instances: bool, + schema_id: str, + schema_name: str, + default_prefix: str, + default_prefix_url: str, +) -> None: + """Generate LinkML schema from a given GraphQL schema.""" + annotated_schema, _, _ = load_and_process_schema( + schema_paths=schemas, + naming_config_path=naming_config, + selection_query_path=selection_query, + root_type=root_type, + expanded_instances=expanded_instances, + ) + assert_correct_schema(annotated_schema.schema) + + result = translate_to_linkml( + annotated_schema, + schema_id, + schema_name, + default_prefix, + default_prefix_url, + ) + _ = output.write_text(result) + + # Export -> json schema # ---------- @export.command diff --git a/src/s2dm/exporters/linkml/__init__.py b/src/s2dm/exporters/linkml/__init__.py new file mode 100644 index 00000000..70935805 --- /dev/null +++ b/src/s2dm/exporters/linkml/__init__.py @@ -0,0 +1,5 @@ +"""LinkML exporter module for S2DM.""" + +from .linkml import translate_to_linkml + +__all__ = ["translate_to_linkml"] diff --git a/src/s2dm/exporters/linkml/linkml.py b/src/s2dm/exporters/linkml/linkml.py new file mode 100644 index 00000000..19195d2f --- /dev/null +++ b/src/s2dm/exporters/linkml/linkml.py @@ -0,0 +1,45 @@ +from s2dm import log +from s2dm.exporters.utils.annotated_schema import AnnotatedSchema + +from .transformer import LinkmlTransformer + + +def transform( + annotated_schema: AnnotatedSchema, + schema_id: str, + schema_name: str, + default_prefix: str, + default_prefix_url: str, +) -> str: + """Transform an annotated GraphQL schema into LinkML schema YAML.""" + log.info(f"Transforming GraphQL schema to LinkML with {len(annotated_schema.schema.type_map)} types") + + transformer = LinkmlTransformer( + annotated_schema, + schema_id, + schema_name, + default_prefix, + default_prefix_url, + ) + linkml_schema = transformer.transform() + + log.info("Successfully converted GraphQL schema to LinkML") + + return linkml_schema + + +def translate_to_linkml( + annotated_schema: AnnotatedSchema, + schema_id: str, + schema_name: str, + default_prefix: str, + default_prefix_url: str, +) -> str: + """Translate an annotated GraphQL schema into LinkML schema YAML.""" + return transform( + annotated_schema, + schema_id, + schema_name, + default_prefix, + default_prefix_url, + ) diff --git a/src/s2dm/exporters/linkml/transformer.py b/src/s2dm/exporters/linkml/transformer.py new file mode 100644 index 00000000..1c4da557 --- /dev/null +++ b/src/s2dm/exporters/linkml/transformer.py @@ -0,0 +1,390 @@ +from collections.abc import Mapping +from typing import Any + +import yaml +from graphql import ( + GraphQLEnumType, + GraphQLEnumValue, + GraphQLField, + GraphQLInputField, + GraphQLInputObjectType, + GraphQLInterfaceType, + GraphQLList, + GraphQLNamedType, + GraphQLNonNull, + GraphQLObjectType, + GraphQLScalarType, + GraphQLUnionType, + Undefined, + get_named_type, +) +from graphql.language.ast import ValueNode +from graphql.language.printer import print_ast +from linkml_runtime.linkml_model.meta import ( + AnonymousSlotExpression, + ClassDefinition, + EnumDefinition, + PermissibleValue, + SchemaDefinition, + SlotDefinition, + TypeDefinition, +) +from linkml_runtime.linkml_model.units import UnitOfMeasure +from linkml_runtime.utils.schema_as_dict import schema_as_dict + +from s2dm.exporters.utils.annotated_schema import AnnotatedSchema +from s2dm.exporters.utils.directive import get_argument_content, get_directive_arguments, has_given_directive +from s2dm.exporters.utils.field import FieldCase, get_cardinality, get_field_case +from s2dm.exporters.utils.graphql_type import is_builtin_scalar_type + +SCALAR_RANGE_MAP: Mapping[str, str] = { + "String": "string", + "Int": "integer", + "Float": "float", + "Boolean": "boolean", + "ID": "string", + "Int8": "integer", + "UInt8": "integer", + "Int16": "integer", + "UInt16": "integer", + "UInt32": "integer", + "Int64": "integer", + "UInt64": "integer", +} + + +class LinkmlTransformer: + """Transform an annotated GraphQL schema to a LinkML schema document.""" + + def __init__( + self, + annotated_schema: AnnotatedSchema, + schema_id: str, + schema_name: str, + default_prefix: str, + default_prefix_url: str, + ) -> None: + self.annotated_schema = annotated_schema + self.schema_id = schema_id + self.schema_name = schema_name + self.default_prefix = default_prefix + self.default_prefix_url = default_prefix_url + + def transform(self) -> str: + """Return a LinkML schema YAML string.""" + schema_definition = SchemaDefinition( + id=self.schema_id, + name=self.schema_name, + prefixes={ + "linkml": "https://w3id.org/linkml/", + self.default_prefix: self.default_prefix_url, + }, + default_prefix=self.default_prefix, + imports=["linkml:types"], + default_range="string", + classes=self._build_classes(), + ) + + enum_definitions = self._build_enums() + if enum_definitions: + schema_definition.enums = enum_definitions + + type_definitions = self._build_custom_scalar_types() + if type_definitions: + schema_definition.types = type_definitions + + return yaml.safe_dump(schema_as_dict(schema_definition), sort_keys=False) + + def _build_classes(self) -> dict[str, ClassDefinition]: + class_definitions: dict[str, ClassDefinition] = {} + + for type_name in self.annotated_schema.schema.type_map: + named_type = self.annotated_schema.schema.type_map[type_name] + if not isinstance(named_type, GraphQLObjectType | GraphQLInputObjectType | GraphQLInterfaceType) or ( + self._is_intermediate_type(named_type.name) + ): + continue + + class_definition = ClassDefinition( + name=named_type.name, + attributes=self._build_attributes(named_type), + ) + + if named_type.description: + class_definition.description = named_type.description + + if isinstance(named_type, GraphQLInterfaceType): + class_definition.abstract = True + + if isinstance(named_type, GraphQLObjectType): + interface_names = [ + interface.name + for interface in named_type.interfaces + if not self._is_intermediate_type(interface.name) + ] + if interface_names: + class_definition.is_a = interface_names[0] + class_definition.mixins = interface_names[1:] + + self._apply_metadata_annotations_from_source(class_definition, named_type) + class_definitions[named_type.name] = class_definition + + return class_definitions + + def _build_enums(self) -> dict[str, EnumDefinition]: + enum_definitions: dict[str, EnumDefinition] = {} + + for type_name in self.annotated_schema.schema.type_map: + named_type = self.annotated_schema.schema.type_map[type_name] + if not isinstance(named_type, GraphQLEnumType) or self._is_intermediate_type(named_type.name): + continue + + enum_definition = EnumDefinition( + name=named_type.name, + permissible_values={ + enum_value_name: PermissibleValue(text=enum_value_name) for enum_value_name in named_type.values + }, + ) + if named_type.description: + enum_definition.description = named_type.description + enum_definitions[named_type.name] = enum_definition + + return enum_definitions + + def _build_custom_scalar_types(self) -> dict[str, TypeDefinition]: + type_definitions: dict[str, TypeDefinition] = {} + + for type_name in self.annotated_schema.schema.type_map: + named_type = self.annotated_schema.schema.type_map[type_name] + if not isinstance(named_type, GraphQLScalarType) or self._is_intermediate_type(named_type.name): + continue + if is_builtin_scalar_type(named_type.name): + continue + if named_type.name in SCALAR_RANGE_MAP: + continue + + type_definition = TypeDefinition(name=named_type.name, base="string") + if named_type.description: + type_definition.description = named_type.description + type_definitions[named_type.name] = type_definition + + return type_definitions + + def _build_attributes( + self, + named_type: GraphQLObjectType | GraphQLInputObjectType | GraphQLInterfaceType, + ) -> dict[str, SlotDefinition]: + attributes: dict[str, SlotDefinition] = {} + + for field_name in named_type.fields: + field = named_type.fields[field_name] + attributes[field_name] = self._build_slot_definition(named_type.name, field_name, field) + + return attributes + + def _build_slot_definition( + self, + parent_type_name: str, + field_name: str, + field: GraphQLField | GraphQLInputField, + ) -> SlotDefinition: + named_type = get_named_type(field.type) + required, multivalued, list_item_required = self._extract_multiplicity(field) + + field_metadata = self.annotated_schema.field_metadata.get((parent_type_name, field_name)) + if field_metadata and field_metadata.is_expanded: + slot_definition = SlotDefinition(name=field_name, range=field_metadata.resolved_type) + else: + slot_definition = self._build_slot_with_range(field_name, named_type) + + if required: + slot_definition.required = True + if multivalued: + slot_definition.multivalued = True + if list_item_required and multivalued: + self._merge_annotations(slot_definition, {"s2dm_list_item_required": "true"}) + + if field.description: + slot_definition.description = field.description + + self._apply_unit_mapping(slot_definition, field) + self._apply_field_constraints(slot_definition, field, multivalued) + self._apply_metadata_annotations_from_source(slot_definition, field) + + return slot_definition + + def _build_slot_with_range(self, field_name: str, named_type: GraphQLNamedType) -> SlotDefinition: + if isinstance(named_type, GraphQLUnionType): + any_of_ranges = [ + AnonymousSlotExpression(range=member_type.name) + for member_type in named_type.types + if not self._is_intermediate_type(member_type.name) + ] + if any_of_ranges: + return SlotDefinition(name=field_name, any_of=any_of_ranges) + return SlotDefinition(name=field_name, range="string") + + return SlotDefinition(name=field_name, range=self._to_linkml_range(named_type)) + + def _extract_multiplicity(self, field: GraphQLField | GraphQLInputField) -> tuple[bool, bool, bool]: + if isinstance(field, GraphQLField): + field_case = get_field_case(field) + return self._multiplicity_from_field_case(field_case) + + field_type = field.type + if isinstance(field_type, GraphQLNonNull): + required = True + unwrapped_type = field_type.of_type + else: + required = False + unwrapped_type = field_type + + if isinstance(unwrapped_type, GraphQLList): + multivalued = True + item_required = isinstance(unwrapped_type.of_type, GraphQLNonNull) + else: + multivalued = False + item_required = False + + return required, multivalued, item_required + + def _multiplicity_from_field_case(self, field_case: FieldCase) -> tuple[bool, bool, bool]: + """Map a GraphQL field case to required/list/item-nullability flags. + + Returns: + tuple[bool, bool, bool]: A tuple of `(required, multivalued, list_item_required)` where + `required` indicates whether the slot itself is non-null, + `multivalued` indicates whether the slot is a list, + and `list_item_required` indicates whether list items are non-null. + """ + if field_case == FieldCase.DEFAULT: + return False, False, False + if field_case == FieldCase.NON_NULL: + return True, False, False + if field_case == FieldCase.LIST: + return False, True, False + if field_case == FieldCase.NON_NULL_LIST: + return True, True, False + if field_case == FieldCase.LIST_NON_NULL: + return False, True, True + + return True, True, True + + def _apply_field_constraints( + self, + slot_definition: SlotDefinition, + field: GraphQLField | GraphQLInputField, + multivalued: bool, + ) -> None: + range_arguments = get_directive_arguments(field, "range") if has_given_directive(field, "range") else {} + min_value = range_arguments.get("min") + max_value = range_arguments.get("max") + if isinstance(min_value, int | float): + slot_definition.minimum_value = min_value + if isinstance(max_value, int | float): + slot_definition.maximum_value = max_value + + cardinality = get_cardinality(field) + if cardinality: + if cardinality.min is not None: + slot_definition.minimum_cardinality = cardinality.min + if cardinality.max is not None: + slot_definition.maximum_cardinality = cardinality.max + + if multivalued and has_given_directive(field, "noDuplicates"): + slot_definition.list_elements_unique = True + + def _apply_unit_mapping(self, slot_definition: SlotDefinition, field: GraphQLField | GraphQLInputField) -> None: + if not isinstance(field, GraphQLField): + return + + unit_argument = field.args.get("unit") + if unit_argument is None: + return + + default_unit = unit_argument.default_value + if default_unit is None or default_unit is Undefined: + return + + unit_type = get_named_type(unit_argument.type) + if not isinstance(unit_type, GraphQLEnumType): + return + + enum_match = self._resolve_enum_value(unit_type, str(default_unit)) + if enum_match is None: + return + + unit_symbol, enum_value = enum_match + unit_payload: dict[str, Any] = {"symbol": unit_symbol} + + unit_uri = get_argument_content(enum_value, "reference", "uri") + if unit_uri: + unit_payload["exact_mappings"] = [unit_uri] + + quantity_kind_uri = get_argument_content(unit_type, "reference", "uri") + if quantity_kind_uri: + unit_payload["has_quantity_kind"] = quantity_kind_uri + + slot_definition.unit = UnitOfMeasure(**unit_payload) + + def _resolve_enum_value( + self, + enum_type: GraphQLEnumType, + default_symbol: str, + ) -> tuple[str, GraphQLEnumValue] | None: + direct_match = enum_type.values.get(default_symbol) + if direct_match is not None: + return default_symbol, direct_match + + for enum_symbol, enum_value in enum_type.values.items(): + if str(enum_value.value) == default_symbol: + return enum_symbol, enum_value + + return None + + def _apply_metadata_annotations_from_source( + self, + target_definition: SlotDefinition | ClassDefinition, + source_element: GraphQLField + | GraphQLInputField + | GraphQLObjectType + | GraphQLInputObjectType + | GraphQLInterfaceType, + ) -> None: + if not has_given_directive(source_element, "metadata"): + return + + metadata_arguments = get_directive_arguments(source_element, "metadata") + annotations = { + f"s2dm_metadata_{key}": self._stringify_directive_value(value) + for key, value in metadata_arguments.items() + if value is not None + } + self._merge_annotations(target_definition, annotations) + + def _merge_annotations( + self, + target_definition: SlotDefinition | ClassDefinition, + annotations: dict[str, str], + ) -> None: + if not annotations: + return + + existing_annotations = target_definition.annotations or {} + merged_annotations: dict[str, Any] = {str(key): value for key, value in existing_annotations.items()} + merged_annotations.update(annotations) + target_definition.annotations = merged_annotations + + def _stringify_directive_value(self, value: Any) -> str: + if isinstance(value, ValueNode): + return print_ast(value) + return str(value) + + def _to_linkml_range(self, named_type: GraphQLNamedType) -> str: + if isinstance(named_type, GraphQLScalarType): + return SCALAR_RANGE_MAP.get(named_type.name, named_type.name) + return named_type.name + + def _is_intermediate_type(self, type_name: str) -> bool: + type_metadata = self.annotated_schema.type_metadata.get(type_name) + return bool(type_metadata and type_metadata.is_intermediate_type) diff --git a/src/s2dm/exporters/utils/directive.py b/src/s2dm/exporters/utils/directive.py index f03089e2..97ad9026 100644 --- a/src/s2dm/exporters/utils/directive.py +++ b/src/s2dm/exporters/utils/directive.py @@ -5,7 +5,9 @@ DirectiveLocation, FloatValueNode, GraphQLEnumType, + GraphQLEnumValue, GraphQLField, + GraphQLInputField, GraphQLInputObjectType, GraphQLInterfaceType, GraphQLObjectType, @@ -19,6 +21,16 @@ GRAPHQL_TYPE_DEFINITION_PATTERN = r"^(type|interface|input|enum|union|scalar)\s+(\w+)" +DirectiveElement = ( + GraphQLField + | GraphQLInputField + | GraphQLObjectType + | GraphQLInterfaceType + | GraphQLInputObjectType + | GraphQLEnumType + | GraphQLEnumValue +) + def get_type_directive_location(graphql_type: GraphQLType) -> DirectiveLocation | None: """Get the directive location for a GraphQL type.""" @@ -37,7 +49,7 @@ def get_type_directive_location(graphql_type: GraphQLType) -> DirectiveLocation return None -def get_directive_arguments(element: GraphQLField | GraphQLObjectType, directive_name: str) -> dict[str, Any]: +def get_directive_arguments(element: DirectiveElement, directive_name: str) -> dict[str, Any]: """ Extracts the arguments of a specified directive from a GraphQL element. Args: @@ -54,20 +66,21 @@ def get_directive_arguments(element: GraphQLField | GraphQLObjectType, directive for arg in directive.arguments: arg_name = arg.name.value - if hasattr(arg.value, "value"): - if isinstance(arg.value, IntValueNode): - args[arg_name] = int(arg.value.value) - elif isinstance(arg.value, FloatValueNode): - args[arg_name] = float(arg.value.value) - else: - args[arg_name] = arg.value.value + value_node: Any = arg.value + raw_value = getattr(value_node, "value", None) + if isinstance(value_node, IntValueNode): + args[arg_name] = int(value_node.value) + elif isinstance(value_node, FloatValueNode): + args[arg_name] = float(value_node.value) + elif raw_value is not None: + args[arg_name] = raw_value else: - args[arg_name] = arg.value + args[arg_name] = value_node return args -def has_given_directive(element: GraphQLObjectType | GraphQLField, directive_name: str) -> bool: +def has_given_directive(element: DirectiveElement, directive_name: str) -> bool: """Check whether a GraphQL element (field, object type) has a particular specified directive.""" if element.ast_node and element.ast_node.directives: for directive in element.ast_node.directives: @@ -76,9 +89,7 @@ def has_given_directive(element: GraphQLObjectType | GraphQLField, directive_nam return False -def get_argument_content( - element: GraphQLObjectType | GraphQLField, directive_name: str, argument_name: str -) -> Any | None: +def get_argument_content(element: DirectiveElement, directive_name: str, argument_name: str) -> Any | None: """ Extracts the comment from a GraphQL element (field or named type). @@ -155,7 +166,7 @@ def get_directive_strings(value: Any) -> list[str]: directive_map[type_name] = directive_strings # Directives on fields - if hasattr(type_obj, "fields"): + if isinstance(type_obj, GraphQLObjectType | GraphQLInterfaceType | GraphQLInputObjectType): for field_name, field in type_obj.fields.items(): if has_directives(field): directive_strings = get_directive_strings(field) diff --git a/src/s2dm/exporters/utils/field.py b/src/s2dm/exporters/utils/field.py index 72544f98..e7e83208 100644 --- a/src/s2dm/exporters/utils/field.py +++ b/src/s2dm/exporters/utils/field.py @@ -1,7 +1,7 @@ from dataclasses import dataclass from enum import Enum -from graphql import GraphQLField, is_list_type, is_non_null_type +from graphql import GraphQLField, GraphQLInputField, is_list_type, is_non_null_type from s2dm.exporters.utils.directive import get_directive_arguments, has_given_directive @@ -119,12 +119,12 @@ def get_field_case_extended(field: GraphQLField) -> FieldCase: return base_case -def get_cardinality(field: GraphQLField) -> Cardinality | None: +def get_cardinality(field: GraphQLField | GraphQLInputField) -> Cardinality | None: """ Extracts the @cardinality directive arguments from a GraphQL field, if present. Args: - field (GraphQLField): The field to extract cardinality from. + field (GraphQLField | GraphQLInputField): The field to extract cardinality from. Returns: Cardinality | None: The Cardinality if the directive is present, otherwise None. diff --git a/src/s2dm/exporters/utils/schema_loader.py b/src/s2dm/exporters/utils/schema_loader.py index b0bc88ba..8986337d 100644 --- a/src/s2dm/exporters/utils/schema_loader.py +++ b/src/s2dm/exporters/utils/schema_loader.py @@ -52,7 +52,7 @@ get_type_directive_location, has_given_directive, ) -from s2dm.exporters.utils.graphql_type import is_introspection_or_root_type +from s2dm.exporters.utils.graphql_type import is_introspection_or_root_type, is_introspection_type from s2dm.exporters.utils.instance_tag import expand_instances_in_schema from s2dm.exporters.utils.naming import apply_naming_to_schema, convert_name, load_naming_config from s2dm.exporters.utils.naming_config import ContextType, ElementType, NamingConventionConfig, get_case_for_element @@ -800,6 +800,16 @@ def build_annotated_schema( return AnnotatedSchema(schema=schema, type_metadata=type_metadata, field_metadata=field_metadata) +def remove_introspection_types(schema: GraphQLSchema) -> GraphQLSchema: + """Remove GraphQL introspection types from the schema type map.""" + introspection_types = [type_name for type_name in schema.type_map if is_introspection_type(type_name)] + + for type_name in introspection_types: + del schema.type_map[type_name] + + return schema + + def process_schema( schema: GraphQLSchema, source_map: dict[str, str], @@ -836,6 +846,8 @@ def process_schema( if expanded_instances: schema, expansion_type_meta, expansion_field_meta = expand_instances_in_schema(schema, naming_config) + schema = remove_introspection_types(schema) + return build_annotated_schema(schema, source_map, expansion_type_meta, expansion_field_meta) diff --git a/src/s2dm/tools/validators.py b/src/s2dm/tools/validators.py index 94c794da..b76237b6 100644 --- a/src/s2dm/tools/validators.py +++ b/src/s2dm/tools/validators.py @@ -1,5 +1,6 @@ import click import langcodes +from linkml_runtime.utils.metamodelcore import URI def validate_language_tag(ctx: click.Context, param: click.Parameter, value: str) -> str: @@ -29,3 +30,43 @@ def validate_language_tag(ctx: click.Context, param: click.Parameter, value: str raise click.BadParameter(f"'{value}' is not a valid BCP 47 language tag: {str(e)}") from None return value + + +def validate_linkml_uri_value(value: str) -> str: + """Validate a value against LinkML URI semantics. + + Args: + value: The URI value to validate + + Returns: + The validated and trimmed URI string + + Raises: + ValueError: If the URI is empty or invalid + """ + normalized_value = value.strip() + if not normalized_value: + raise ValueError("Value cannot be empty") + if not URI.is_valid(normalized_value): + raise ValueError(f"'{value}' is not a valid LinkML URI value") + return normalized_value + + +def validate_linkml_uri(ctx: click.Context, param: click.Parameter, value: str) -> str: + """Validate a LinkML URI value for Click options. + + Args: + ctx: Click context + param: Click parameter + value: The URI value to validate + + Returns: + The validated and trimmed URI string + + Raises: + click.BadParameter: If the URI is empty or invalid + """ + try: + return validate_linkml_uri_value(value) + except ValueError as error: + raise click.BadParameter(str(error)) from None diff --git a/tests/test_api_exports.py b/tests/test_api_exports.py index 9cc07f5a..eb6a02cd 100644 --- a/tests/test_api_exports.py +++ b/tests/test_api_exports.py @@ -8,6 +8,11 @@ import pytest from fastapi.testclient import TestClient +LINKML_SCHEMA_ID = "https://covesa.global/s2dm" +LINKML_SCHEMA_NAME = "TestSchema" +LINKML_DEFAULT_PREFIX = "s2dm" +LINKML_DEFAULT_PREFIX_URL = "https://covesa.global/s2dm" + class TestExporters: """Test exporters endpoints.""" @@ -150,6 +155,145 @@ def test_vspec_export(self, test_client: TestClient) -> None: data = response.json() assert data["metadata"]["result_format"] == "vspec" + def test_linkml_export(self, test_client: TestClient) -> None: + """Export to LinkML format.""" + simple_schema = "type Query { vehicle: Vehicle } type Vehicle { id: ID! }" + + response = test_client.post( + "/api/v1/export/linkml", + json={ + "schemas": [{"type": "content", "content": simple_schema}], + "id": LINKML_SCHEMA_ID, + "name": LINKML_SCHEMA_NAME, + "default_prefix": LINKML_DEFAULT_PREFIX, + "default_prefix_url": LINKML_DEFAULT_PREFIX_URL, + }, + ) + + assert response.status_code == 200 + data = response.json() + assert data["metadata"]["result_format"] == "yaml" + + def test_linkml_export_missing_id_returns_400(self, test_client: TestClient) -> None: + """LinkML export returns 400 when id is missing.""" + simple_schema = "type Query { vehicle: Vehicle } type Vehicle { id: ID! }" + + response = test_client.post( + "/api/v1/export/linkml", + json={ + "schemas": [{"type": "content", "content": simple_schema}], + "name": LINKML_SCHEMA_NAME, + "default_prefix": LINKML_DEFAULT_PREFIX, + "default_prefix_url": LINKML_DEFAULT_PREFIX_URL, + }, + ) + + assert response.status_code == 400 + data = response.json() + assert data["error"] == "BadRequest" + + def test_linkml_export_missing_name_returns_400(self, test_client: TestClient) -> None: + """LinkML export returns 400 when name is missing.""" + simple_schema = "type Query { vehicle: Vehicle } type Vehicle { id: ID! }" + + response = test_client.post( + "/api/v1/export/linkml", + json={ + "schemas": [{"type": "content", "content": simple_schema}], + "id": LINKML_SCHEMA_ID, + "default_prefix": LINKML_DEFAULT_PREFIX, + "default_prefix_url": LINKML_DEFAULT_PREFIX_URL, + }, + ) + + assert response.status_code == 400 + data = response.json() + assert data["error"] == "BadRequest" + + def test_linkml_export_missing_default_prefix_returns_400(self, test_client: TestClient) -> None: + """LinkML export returns 400 when default_prefix is missing.""" + simple_schema = "type Query { vehicle: Vehicle } type Vehicle { id: ID! }" + + response = test_client.post( + "/api/v1/export/linkml", + json={ + "schemas": [{"type": "content", "content": simple_schema}], + "id": LINKML_SCHEMA_ID, + "name": LINKML_SCHEMA_NAME, + "default_prefix_url": LINKML_DEFAULT_PREFIX_URL, + }, + ) + + assert response.status_code == 400 + data = response.json() + assert data["error"] == "BadRequest" + + def test_linkml_export_missing_default_prefix_url_returns_400(self, test_client: TestClient) -> None: + """LinkML export returns 400 when default_prefix_url is missing.""" + simple_schema = "type Query { vehicle: Vehicle } type Vehicle { id: ID! }" + + response = test_client.post( + "/api/v1/export/linkml", + json={ + "schemas": [{"type": "content", "content": simple_schema}], + "id": LINKML_SCHEMA_ID, + "name": LINKML_SCHEMA_NAME, + "default_prefix": LINKML_DEFAULT_PREFIX, + }, + ) + + assert response.status_code == 400 + data = response.json() + assert data["error"] == "BadRequest" + + def test_linkml_export_invalid_id_returns_400(self, test_client: TestClient) -> None: + """LinkML export returns 400 when id is not a valid LinkML URI value.""" + simple_schema = "type Query { vehicle: Vehicle } type Vehicle { id: ID! }" + + response = test_client.post( + "/api/v1/export/linkml", + json={ + "schemas": [{"type": "content", "content": simple_schema}], + "id": "foo bar", + "name": LINKML_SCHEMA_NAME, + "default_prefix": LINKML_DEFAULT_PREFIX, + "default_prefix_url": LINKML_DEFAULT_PREFIX_URL, + }, + ) + + assert response.status_code == 400 + data = response.json() + assert data["error"] == "BadRequest" + assert "validation_errors" in data["details"] + assert any( + "valid LinkML URI value" in validation_error["msg"] + for validation_error in data["details"]["validation_errors"] + ) + + def test_linkml_export_invalid_default_prefix_url_returns_400(self, test_client: TestClient) -> None: + """LinkML export returns 400 when default_prefix_url is not a valid LinkML URI value.""" + simple_schema = "type Query { vehicle: Vehicle } type Vehicle { id: ID! }" + + response = test_client.post( + "/api/v1/export/linkml", + json={ + "schemas": [{"type": "content", "content": simple_schema}], + "id": LINKML_SCHEMA_ID, + "name": LINKML_SCHEMA_NAME, + "default_prefix": LINKML_DEFAULT_PREFIX, + "default_prefix_url": "foo bar", + }, + ) + + assert response.status_code == 400 + data = response.json() + assert data["error"] == "BadRequest" + assert "validation_errors" in data["details"] + assert any( + "valid LinkML URI value" in validation_error["msg"] + for validation_error in data["details"]["validation_errors"] + ) + def test_export_accepts_url_schema_input(self, test_client: TestClient, tmp_path: Path) -> None: """Export route accepts URL schema input and processes it.""" schema_file = tmp_path / "schema.graphql" @@ -322,6 +466,32 @@ def test_vspec_route_calls_internal_functions(self, test_client: TestClient) -> schema_check_mock.assert_called_once() exporter_mock.assert_called_once() + def test_linkml_route_calls_internal_functions(self, test_client: TestClient) -> None: + """LinkML route calls wrapper, schema check, and exporter.""" + payload = { + "schemas": [{"type": "content", "content": "type Query { vehicle: Vehicle } type Vehicle { id: ID! }"}], + "id": LINKML_SCHEMA_ID, + "name": LINKML_SCHEMA_NAME, + "default_prefix": LINKML_DEFAULT_PREFIX, + "default_prefix_url": LINKML_DEFAULT_PREFIX_URL, + } + + with ( + patch( + "s2dm.api.routes.linkml.load_and_process_schema_wrapper", + return_value=(SimpleNamespace(schema=object()), object()), + ) as wrapper_mock, + patch("s2dm.api.routes.linkml.check_correct_schema", return_value=[]) as schema_check_mock, + patch("s2dm.api.routes.linkml.translate_to_linkml", return_value="name: test_schema") as exporter_mock, + ): + response = test_client.post("/api/v1/export/linkml", json=payload) + + assert response.status_code == 200 + assert response.json()["metadata"]["result_format"] == "yaml" + wrapper_mock.assert_called_once() + schema_check_mock.assert_called_once() + exporter_mock.assert_called_once() + class TestExportSchemaValidationGuards: """Test that exporters are skipped when schema validation fails.""" @@ -396,6 +566,20 @@ class TestExportSchemaValidationGuards: "s2dm.api.routes.vspec", "translate_to_vspec", ), + ( + "/api/v1/export/linkml", + { + "schemas": [ + {"type": "content", "content": "type Query { vehicle: Vehicle } type Vehicle { id: ID! }"} + ], + "id": LINKML_SCHEMA_ID, + "name": LINKML_SCHEMA_NAME, + "default_prefix": LINKML_DEFAULT_PREFIX, + "default_prefix_url": LINKML_DEFAULT_PREFIX_URL, + }, + "s2dm.api.routes.linkml", + "translate_to_linkml", + ), ], ) def test_exporter_not_called_when_schema_invalid( diff --git a/tests/test_e2e_cli.py b/tests/test_e2e_cli.py index 6540b83d..5eb4f7aa 100644 --- a/tests/test_e2e_cli.py +++ b/tests/test_e2e_cli.py @@ -1,15 +1,22 @@ import json from collections.abc import Callable from pathlib import Path -from typing import Any +from typing import Any, cast import pytest +from click.exceptions import MissingParameter from click.testing import CliRunner +from linkml_runtime.loaders import yaml_loader from s2dm.cli import cli from s2dm.tools.string import normalize_whitespace from tests.conftest import TestSchemaData as TSD +LINKML_SCHEMA_ID = "https://covesa.global/s2dm" +LINKML_SCHEMA_NAME = "TestSchema" +LINKML_DEFAULT_PREFIX = "s2dm" +LINKML_DEFAULT_PREFIX_URL = "https://covesa.global/s2dm" + @pytest.fixture(scope="session") def units_directory() -> Path: @@ -145,6 +152,257 @@ def test_export_jsonschema(runner: CliRunner, tmp_outputs: Path, spec_directory: assert '"Vehicle_ADAS_ObstacleDetection"' in content +def test_export_linkml(runner: CliRunner, tmp_outputs: Path, spec_directory: Path, units_directory: Path) -> None: + out = tmp_outputs / "linkml.yaml" + result = runner.invoke( + cli, + [ + "export", + "linkml", + "-s", + str(spec_directory), + "-s", + str(TSD.SAMPLE1_1), + "-s", + str(TSD.SAMPLE1_2), + "-s", + str(units_directory), + "-o", + str(out), + "-i", + LINKML_SCHEMA_ID, + "-n", + LINKML_SCHEMA_NAME, + "--default-prefix", + LINKML_DEFAULT_PREFIX, + "--default-prefix-url", + LINKML_DEFAULT_PREFIX_URL, + ], + color=False, + ) + assert result.exit_code == 0, result.output + assert out.exists() + + schema = cast(dict[str, Any], yaml_loader.load_as_dict(str(out))) + + assert schema["id"] == LINKML_SCHEMA_ID + assert schema["name"] == LINKML_SCHEMA_NAME + assert "classes" in schema + assert "enums" in schema + assert "Vehicle" in schema["classes"] + assert "Vehicle_ADAS_ObstacleDetection" in schema["classes"] + + +def test_export_linkml_missing_id_fails( + runner: CliRunner, tmp_outputs: Path, spec_directory: Path, units_directory: Path +) -> None: + out = tmp_outputs / "linkml_missing_id.yaml" + result = runner.invoke( + cli, + [ + "export", + "linkml", + "-s", + str(spec_directory), + "-s", + str(TSD.SAMPLE1_1), + "-s", + str(TSD.SAMPLE1_2), + "-s", + str(units_directory), + "-o", + str(out), + "-n", + LINKML_SCHEMA_NAME, + "--default-prefix", + LINKML_DEFAULT_PREFIX, + "--default-prefix-url", + LINKML_DEFAULT_PREFIX_URL, + ], + standalone_mode=False, + ) + + assert isinstance(result.exception, MissingParameter) + assert result.exception.param is not None + assert result.exception.param.name == "schema_id" + assert result.exception.format_message() == "Missing option '--id' / '-i'." + + +def test_export_linkml_missing_name_fails( + runner: CliRunner, tmp_outputs: Path, spec_directory: Path, units_directory: Path +) -> None: + out = tmp_outputs / "linkml_missing_name.yaml" + result = runner.invoke( + cli, + [ + "export", + "linkml", + "-s", + str(spec_directory), + "-s", + str(TSD.SAMPLE1_1), + "-s", + str(TSD.SAMPLE1_2), + "-s", + str(units_directory), + "-o", + str(out), + "-i", + LINKML_SCHEMA_ID, + "--default-prefix", + LINKML_DEFAULT_PREFIX, + "--default-prefix-url", + LINKML_DEFAULT_PREFIX_URL, + ], + standalone_mode=False, + ) + + assert isinstance(result.exception, MissingParameter) + assert result.exception.param is not None + assert result.exception.param.name == "schema_name" + assert result.exception.format_message() == "Missing option '--name' / '-n'." + + +def test_export_linkml_missing_default_prefix_fails( + runner: CliRunner, tmp_outputs: Path, spec_directory: Path, units_directory: Path +) -> None: + out = tmp_outputs / "linkml_missing_default_prefix.yaml" + result = runner.invoke( + cli, + [ + "export", + "linkml", + "-s", + str(spec_directory), + "-s", + str(TSD.SAMPLE1_1), + "-s", + str(TSD.SAMPLE1_2), + "-s", + str(units_directory), + "-o", + str(out), + "-i", + LINKML_SCHEMA_ID, + "-n", + LINKML_SCHEMA_NAME, + "--default-prefix-url", + LINKML_DEFAULT_PREFIX_URL, + ], + standalone_mode=False, + ) + + assert isinstance(result.exception, MissingParameter) + assert result.exception.param is not None + assert result.exception.param.name == "default_prefix" + assert result.exception.format_message() == "Missing option '--default-prefix'." + + +def test_export_linkml_missing_default_prefix_url_fails( + runner: CliRunner, tmp_outputs: Path, spec_directory: Path, units_directory: Path +) -> None: + out = tmp_outputs / "linkml_missing_default_prefix_url.yaml" + result = runner.invoke( + cli, + [ + "export", + "linkml", + "-s", + str(spec_directory), + "-s", + str(TSD.SAMPLE1_1), + "-s", + str(TSD.SAMPLE1_2), + "-s", + str(units_directory), + "-o", + str(out), + "-i", + LINKML_SCHEMA_ID, + "-n", + LINKML_SCHEMA_NAME, + "--default-prefix", + LINKML_DEFAULT_PREFIX, + ], + standalone_mode=False, + ) + + assert isinstance(result.exception, MissingParameter) + assert result.exception.param is not None + assert result.exception.param.name == "default_prefix_url" + assert result.exception.format_message() == "Missing option '--default-prefix-url'." + + +def test_export_linkml_invalid_id_fails( + runner: CliRunner, tmp_outputs: Path, spec_directory: Path, units_directory: Path +) -> None: + out = tmp_outputs / "linkml_invalid_id.yaml" + result = runner.invoke( + cli, + [ + "export", + "linkml", + "-s", + str(spec_directory), + "-s", + str(TSD.SAMPLE1_1), + "-s", + str(TSD.SAMPLE1_2), + "-s", + str(units_directory), + "-o", + str(out), + "-i", + "foo bar", + "-n", + LINKML_SCHEMA_NAME, + "--default-prefix", + LINKML_DEFAULT_PREFIX, + "--default-prefix-url", + LINKML_DEFAULT_PREFIX_URL, + ], + ) + + assert result.exit_code == 2 + assert "not a valid LinkML" in result.output + assert "URI value" in result.output + + +def test_export_linkml_invalid_default_prefix_url_fails( + runner: CliRunner, tmp_outputs: Path, spec_directory: Path, units_directory: Path +) -> None: + out = tmp_outputs / "linkml_invalid_default_prefix_url.yaml" + result = runner.invoke( + cli, + [ + "export", + "linkml", + "-s", + str(spec_directory), + "-s", + str(TSD.SAMPLE1_1), + "-s", + str(TSD.SAMPLE1_2), + "-s", + str(units_directory), + "-o", + str(out), + "-i", + LINKML_SCHEMA_ID, + "-n", + LINKML_SCHEMA_NAME, + "--default-prefix", + LINKML_DEFAULT_PREFIX, + "--default-prefix-url", + "foo bar", + ], + ) + + assert result.exit_code == 2 + assert "not a valid LinkML" in result.output + assert "URI value" in result.output + + def test_export_protobuf(runner: CliRunner, tmp_outputs: Path, spec_directory: Path, units_directory: Path) -> None: out = tmp_outputs / "schema.proto" result = runner.invoke( diff --git a/tests/test_e2e_linkml.py b/tests/test_e2e_linkml.py new file mode 100644 index 00000000..25d88386 --- /dev/null +++ b/tests/test_e2e_linkml.py @@ -0,0 +1,134 @@ +"""End-to-end integration tests for LinkML export.""" + +import json +from pathlib import Path +from typing import Any, cast + +import pytest +from linkml_runtime.loaders import yaml_loader + +from s2dm.exporters.linkml import translate_to_linkml +from s2dm.exporters.utils.schema_loader import load_and_process_schema + +LINKML_SCHEMA_ID = "https://covesa.global/s2dm" +LINKML_SCHEMA_NAME = "TestSchema" +LINKML_DEFAULT_PREFIX = "s2dm" +LINKML_DEFAULT_PREFIX_URL = "https://covesa.global/s2dm" + + +class TestLinkmlE2E: + @pytest.fixture + def test_schema_path(self, spec_directory: Path) -> list[Path]: + """Path to the test GraphQL schema.""" + return [spec_directory, Path(__file__).parent / "test_expanded_instances" / "test_schema.graphql"] + + def test_expanded_instances_default(self, test_schema_path: list[Path]) -> None: + """Test that instance tags are NOT expanded by default.""" + annotated_schema, _, _ = load_and_process_schema( + schema_paths=test_schema_path, + naming_config_path=None, + selection_query_path=None, + root_type="Cabin", + expanded_instances=False, + ) + + result = translate_to_linkml( + annotated_schema, + LINKML_SCHEMA_ID, + LINKML_SCHEMA_NAME, + LINKML_DEFAULT_PREFIX, + LINKML_DEFAULT_PREFIX_URL, + ) + schema = cast(dict[str, Any], yaml_loader.load_as_dict(result)) + cabin_attributes = schema["classes"]["Cabin"]["attributes"] + + assert "doors" in cabin_attributes + assert "seats" in cabin_attributes + assert cabin_attributes["doors"]["multivalued"] is True + assert cabin_attributes["doors"]["range"] == "Door" + assert cabin_attributes["doors"]["list_elements_unique"] is True + assert cabin_attributes["seats"]["multivalued"] is True + assert cabin_attributes["seats"]["range"] == "Seat" + assert cabin_attributes["seats"]["list_elements_unique"] is True + + assert "DoorPosition" in schema["classes"] + assert "SeatPosition" in schema["classes"] + + def test_expanded_instances(self, test_schema_path: list[Path]) -> None: + """Test that instance tags are expanded when enabled.""" + annotated_schema, _, _ = load_and_process_schema( + schema_paths=test_schema_path, + naming_config_path=None, + selection_query_path=None, + root_type="Cabin", + expanded_instances=True, + ) + + result = translate_to_linkml( + annotated_schema, + LINKML_SCHEMA_ID, + LINKML_SCHEMA_NAME, + LINKML_DEFAULT_PREFIX, + LINKML_DEFAULT_PREFIX_URL, + ) + schema = cast(dict[str, Any], yaml_loader.load_as_dict(result)) + cabin_attributes = schema["classes"]["Cabin"]["attributes"] + + assert "Door" in cabin_attributes + assert "Seat" in cabin_attributes + assert "doors" not in cabin_attributes + assert "seats" not in cabin_attributes + + assert cabin_attributes["Door"]["required"] is True + assert cabin_attributes["Seat"]["required"] is True + assert "annotations" not in cabin_attributes["Door"] + assert "annotations" not in cabin_attributes["Seat"] + + assert "Door_Row" not in schema["classes"] + assert "Door_Side" not in schema["classes"] + assert "Seat_Row" not in schema["classes"] + assert "Seat_Position" not in schema["classes"] + + def test_expanded_instances_with_naming_config(self, test_schema_path: list[Path], tmp_path: Path) -> None: + """Test that naming config is applied to expanded instance field names.""" + naming_config = {"field": {"object": "MACROCASE"}} + naming_config_file = tmp_path / "naming_config.json" + naming_config_file.write_text(json.dumps(naming_config)) + + annotated_schema, _, _ = load_and_process_schema( + schema_paths=test_schema_path, + naming_config_path=naming_config_file, + selection_query_path=None, + root_type="Cabin", + expanded_instances=True, + ) + + result = translate_to_linkml( + annotated_schema, + LINKML_SCHEMA_ID, + LINKML_SCHEMA_NAME, + LINKML_DEFAULT_PREFIX, + LINKML_DEFAULT_PREFIX_URL, + ) + schema = cast(dict[str, Any], yaml_loader.load_as_dict(result)) + cabin_attributes = schema["classes"]["Cabin"]["attributes"] + + assert "SEAT" in cabin_attributes + assert "DOOR" in cabin_attributes + assert "Seat" not in cabin_attributes + assert "Door" not in cabin_attributes + assert "annotations" not in cabin_attributes["SEAT"] + assert "annotations" not in cabin_attributes["DOOR"] + + seat_attributes = schema["classes"]["Seat"]["attributes"] + door_attributes = schema["classes"]["Door"]["attributes"] + + assert "IS_OCCUPIED" in seat_attributes + assert "HEIGHT" in seat_attributes + assert "isOccupied" not in seat_attributes + assert "height" not in seat_attributes + + assert "IS_LOCKED" in door_attributes + assert "POSITION" in door_attributes + assert "isLocked" not in door_attributes + assert "position" not in door_attributes diff --git a/tests/test_linkml.py b/tests/test_linkml.py new file mode 100644 index 00000000..722e06c0 --- /dev/null +++ b/tests/test_linkml.py @@ -0,0 +1,316 @@ +"""Tests for the LinkML exporter.""" + +from typing import Any, cast + +from graphql import build_schema +from linkml_runtime.loaders import yaml_loader + +from s2dm.exporters.linkml import translate_to_linkml +from s2dm.exporters.utils.naming_config import NamingConventionConfig, ValidationMode +from s2dm.exporters.utils.schema_loader import process_schema + +LINKML_SCHEMA_ID = "https://covesa.global/s2dm" +LINKML_SCHEMA_NAME = "TestSchema" +LINKML_DEFAULT_PREFIX = "s2dm" +LINKML_DEFAULT_PREFIX_URL = "https://covesa.global/s2dm" + + +def _transform_to_schema_dict( + schema_str: str, + naming_config: NamingConventionConfig | None = None, +) -> dict[str, Any]: + graphql_schema = build_schema(schema_str) + annotated_schema = process_schema( + schema=graphql_schema, + source_map={}, + naming_config=naming_config, + query_document=None, + root_type=None, + expanded_instances=False, + ) + result = translate_to_linkml( + annotated_schema, + LINKML_SCHEMA_ID, + LINKML_SCHEMA_NAME, + LINKML_DEFAULT_PREFIX, + LINKML_DEFAULT_PREFIX_URL, + ) + return cast(dict[str, Any], yaml_loader.load_as_dict(result)) + + +class TestBasicTransformation: + def test_basic_schema_structure(self) -> None: + """Test that basic LinkML schema structure is generated correctly.""" + schema_str = """ + type Query { vehicle: Vehicle } + type Vehicle { id: ID!, make: String! } + """ + + schema = _transform_to_schema_dict(schema_str) + + assert schema["name"] == LINKML_SCHEMA_NAME + assert schema["id"] == LINKML_SCHEMA_ID + assert schema["default_prefix"] == LINKML_DEFAULT_PREFIX + assert schema["prefixes"][LINKML_DEFAULT_PREFIX] == LINKML_DEFAULT_PREFIX_URL + assert "linkml:types" in schema["imports"] + assert "Query" in schema["classes"] + assert "Vehicle" in schema["classes"] + assert schema["classes"]["Query"]["attributes"]["vehicle"]["range"] == "Vehicle" + + def test_object_type_transformation(self) -> None: + """Test that GraphQL object types are correctly transformed.""" + schema_str = """ + type Query { vehicle: Vehicle } + + type Vehicle { + id: ID! + make: String! + model: String + year: Int + } + """ + + schema = _transform_to_schema_dict(schema_str) + vehicle = schema["classes"]["Vehicle"] + + assert "id" in vehicle["attributes"] + assert "make" in vehicle["attributes"] + assert "model" in vehicle["attributes"] + assert "year" in vehicle["attributes"] + + assert vehicle["attributes"]["id"]["range"] == "string" + assert vehicle["attributes"]["id"]["required"] is True + assert vehicle["attributes"]["make"]["required"] is True + assert "required" not in vehicle["attributes"]["model"] + assert vehicle["attributes"]["year"]["range"] == "integer" + + +class TestGraphQLTypeHandling: + def test_enum_types(self) -> None: + """Test that GraphQL enum types are correctly transformed.""" + schema_str = """ + type Query { vehicle: Vehicle } + + enum FuelType { + GASOLINE + DIESEL + ELECTRIC + } + + type Vehicle { + fuelType: FuelType + } + """ + + schema = _transform_to_schema_dict(schema_str) + + assert "FuelType" in schema["enums"] + assert set(schema["enums"]["FuelType"]["permissible_values"].keys()) == {"GASOLINE", "DIESEL", "ELECTRIC"} + assert schema["classes"]["Vehicle"]["attributes"]["fuelType"]["range"] == "FuelType" + + def test_union_types(self) -> None: + """Test that GraphQL union types are correctly transformed.""" + schema_str = """ + type Query { vehicle: Vehicle } + + union Transport = Car | Truck + + type Car { id: ID!, doors: Int! } + type Truck { id: ID!, payloadCapacity: Float! } + + type Vehicle { + transport: Transport + } + """ + + schema = _transform_to_schema_dict(schema_str) + transport_slot = schema["classes"]["Vehicle"]["attributes"]["transport"] + + assert "any_of" in transport_slot + assert {value["range"] for value in transport_slot["any_of"]} == {"Car", "Truck"} + + def test_interface_types(self) -> None: + """Test that GraphQL interface types are correctly transformed.""" + schema_str = """ + type Query { vehicle: Vehicle } + + interface Vehicle { + id: ID! + make: String! + } + + type Car implements Vehicle { + id: ID! + make: String! + doors: Int! + } + """ + + schema = _transform_to_schema_dict(schema_str) + + assert schema["classes"]["Vehicle"]["abstract"] is True + assert schema["classes"]["Car"]["is_a"] == "Vehicle" + assert schema["classes"]["Car"]["attributes"]["doors"]["range"] == "integer" + + def test_custom_scalar_types(self) -> None: + """Test that custom scalar types are correctly transformed.""" + schema_str = """ + scalar DateTime + + type Query { vehicle: Vehicle } + type Vehicle { builtAt: DateTime } + """ + + schema = _transform_to_schema_dict(schema_str) + + assert "DateTime" in schema["types"] + assert schema["types"]["DateTime"]["base"] == "string" + assert schema["classes"]["Vehicle"]["attributes"]["builtAt"]["range"] == "DateTime" + + +class TestDirectiveMapping: + def test_range_cardinality_no_duplicates_and_metadata(self) -> None: + """Test directive mapping to LinkML slot constraints and annotations.""" + schema_str = """ + directive @range(min: Float, max: Float) on FIELD_DEFINITION + directive @cardinality(min: Int, max: Int) on FIELD_DEFINITION + directive @noDuplicates on FIELD_DEFINITION + directive @metadata(comment: String, vssType: String) on FIELD_DEFINITION | OBJECT + + type Query { vehicle: Vehicle } + + type Vehicle @metadata(comment: "Vehicle entity", vssType: "branch") { + speed: Float @range(min: 0, max: 250) + tags: [String] + @cardinality(min: 1, max: 5) + @noDuplicates + @metadata(comment: "Tag values", vssType: "attribute") + } + """ + + schema = _transform_to_schema_dict(schema_str) + vehicle = schema["classes"]["Vehicle"] + speed_slot = vehicle["attributes"]["speed"] + tags_slot = vehicle["attributes"]["tags"] + + assert "description" not in vehicle + assert vehicle["annotations"]["s2dm_metadata_comment"] == "Vehicle entity" + assert vehicle["annotations"]["s2dm_metadata_vssType"] == "branch" + + assert speed_slot["minimum_value"] == 0 + assert speed_slot["maximum_value"] == 250 + + assert tags_slot["multivalued"] is True + assert tags_slot["minimum_cardinality"] == 1 + assert tags_slot["maximum_cardinality"] == 5 + assert tags_slot["list_elements_unique"] is True + assert "description" not in tags_slot + assert tags_slot["annotations"]["s2dm_metadata_comment"] == "Tag values" + assert tags_slot["annotations"]["s2dm_metadata_vssType"] == "attribute" + + +class TestUnitMapping: + def test_qudt_unit_reference_mapping(self) -> None: + """Test that QUDT unit references are mapped to LinkML slot units.""" + schema_str = ''' + directive @reference(uri: String, versionTag: String) on ENUM | ENUM_VALUE + + type Query { vehicle: Vehicle } + + enum TransmittanceDensityUnitEnum + @reference(uri: "http://qudt.org/vocab/quantitykind/TransmittanceDensity", versionTag: "v3.1.8") { + """Unitless""" + UNITLESS @reference(uri: "http://qudt.org/vocab/unit/UNITLESS", versionTag: "v3.1.8") + } + + type Vehicle { + transmittanceDensity(unit: TransmittanceDensityUnitEnum = UNITLESS): Float + } + ''' + + schema = _transform_to_schema_dict(schema_str) + unit = schema["classes"]["Vehicle"]["attributes"]["transmittanceDensity"]["unit"] + + assert unit["symbol"] == "UNITLESS" + assert unit["exact_mappings"] == ["http://qudt.org/vocab/unit/UNITLESS"] + assert unit["has_quantity_kind"] == "http://qudt.org/vocab/quantitykind/TransmittanceDensity" + + def test_qudt_unit_reference_mapping_with_enum_value_renaming(self) -> None: + """Test QUDT unit mapping when enum value naming conversion changes enum keys.""" + schema_str = """ + directive @reference(uri: String, versionTag: String) on ENUM | ENUM_VALUE + + type Query { vehicle: Vehicle } + + enum VelocityUnitEnum @reference(uri: "http://qudt.org/vocab/quantitykind/Velocity", versionTag: "v3.1.8") { + KILOM_PER_HR @reference(uri: "http://qudt.org/vocab/unit/KiloM-PER-HR", versionTag: "v3.1.8") + } + + type Vehicle { + speed(unit: VelocityUnitEnum = KILOM_PER_HR): Float + } + """ + + naming_config = NamingConventionConfig.model_validate( + {"enumValue": "snake_case", "instanceTag": "snake_case"}, + context={"mode": ValidationMode.CONVERSION}, + ) + + schema = _transform_to_schema_dict(schema_str, naming_config=naming_config) + unit = schema["classes"]["Vehicle"]["attributes"]["speed"]["unit"] + + assert unit["symbol"] == "kilom_per_hr" + assert unit["exact_mappings"] == ["http://qudt.org/vocab/unit/KiloM-PER-HR"] + assert unit["has_quantity_kind"] == "http://qudt.org/vocab/quantitykind/Velocity" + + def test_input_types_and_fields_use_same_directive_mapping(self) -> None: + """Test that input types and input fields get the same directive mappings as output types.""" + schema_str = """ + directive @range(min: Float, max: Float) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + directive @cardinality(min: Int, max: Int) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + directive @noDuplicates on FIELD_DEFINITION | INPUT_FIELD_DEFINITION + directive @metadata(comment: String, vssType: String) on + FIELD_DEFINITION | INPUT_FIELD_DEFINITION | OBJECT | INTERFACE | INPUT_OBJECT + + interface BaseEntity @metadata(comment: "Base interface", vssType: "interface") { + id: ID! + } + + input VehicleFilter @metadata(comment: "Filter input", vssType: "input") { + tags: [String] + @cardinality(min: 1, max: 3) + @noDuplicates + @metadata(comment: "Input tags", vssType: "attribute") + confidence: Float @range(min: 0.1, max: 0.9) + } + + type Query { + vehicle(filter: VehicleFilter): Vehicle + } + + type Vehicle implements BaseEntity { + id: ID! + } + """ + + schema = _transform_to_schema_dict(schema_str) + vehicle_filter = schema["classes"]["VehicleFilter"] + tags_slot = vehicle_filter["attributes"]["tags"] + confidence_slot = vehicle_filter["attributes"]["confidence"] + base_entity = schema["classes"]["BaseEntity"] + + assert vehicle_filter["annotations"]["s2dm_metadata_comment"] == "Filter input" + assert vehicle_filter["annotations"]["s2dm_metadata_vssType"] == "input" + + assert tags_slot["multivalued"] is True + assert tags_slot["minimum_cardinality"] == 1 + assert tags_slot["maximum_cardinality"] == 3 + assert tags_slot["list_elements_unique"] is True + assert tags_slot["annotations"]["s2dm_metadata_comment"] == "Input tags" + assert tags_slot["annotations"]["s2dm_metadata_vssType"] == "attribute" + + assert confidence_slot["minimum_value"] == 0.1 + assert confidence_slot["maximum_value"] == 0.9 + + assert base_entity["annotations"]["s2dm_metadata_comment"] == "Base interface" + assert base_entity["annotations"]["s2dm_metadata_vssType"] == "interface" diff --git a/tests/test_schema_processing.py b/tests/test_schema_processing.py index 4e95a668..867ca7bd 100644 --- a/tests/test_schema_processing.py +++ b/tests/test_schema_processing.py @@ -150,3 +150,8 @@ def test_load_with_all_options( assert "CABIN" not in annotated_schema.schema.type_map assert "SEAT" not in annotated_schema.schema.type_map + + def test_load_removes_introspection_types(self, schema_path: list[Path]) -> None: + annotated_schema, _, _ = load_and_process_schema(schema_path, None, None, None, False) + + assert not any(type_name.startswith("__") for type_name in annotated_schema.schema.type_map) diff --git a/uv.lock b/uv.lock index 15476352..dbfc7ee7 100644 --- a/uv.lock +++ b/uv.lock @@ -499,6 +499,19 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "curies" +version = "0.12.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c4/b3/53a8e2e3075d98d89b611a38c275b444eb2a8f3fb4a87b8ea24d15570110/curies-0.12.9.tar.gz", hash = "sha256:bd6826550bd21f0c7508ac9c9869b8dfa4b3376b0bdf4d68fbc461d9bb4af037", size = 283491, upload-time = "2026-01-14T12:10:12.096Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/15/77/b2c333b9d9d908048a854943217a6dab7d2dca991cd87bea3fea6e6a4d45/curies-0.12.9-py3-none-any.whl", hash = "sha256:0f5cc8f5c72d3099dd7cf2a70a56c10664f82b52eda8072d45b7586caf3a5745", size = 70247, upload-time = "2026-01-14T12:10:14.895Z" }, +] + [[package]] name = "debugpy" version = "1.8.15" @@ -550,6 +563,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/99/c7/d1ec24fb280caa5a79b6b950db565dab30210a66259d17d5bb2b3a9f878d/dependency_groups-1.3.1-py3-none-any.whl", hash = "sha256:51aeaa0dfad72430fcfb7bcdbefbd75f3792e5919563077f30bc0d73f4493030", size = 8664, upload-time = "2025-05-02T00:34:27.085Z" }, ] +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -672,6 +697,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] +[[package]] +name = "hbreader" +version = "0.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/66/3a649ce125e03d1d43727a8b833cd211f0b9fe54a7e5be326f50d6f1d951/hbreader-0.9.1.tar.gz", hash = "sha256:d2c132f8ba6276d794c66224c3297cec25c8079d0a4cf019c061611e0a3b94fa", size = 19016, upload-time = "2021-02-25T19:22:32.799Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/24/61844afbf38acf419e01ca2639f7bd079584523d34471acbc4152ee991c5/hbreader-0.9.1-py3-none-any.whl", hash = "sha256:9a6e76c9d1afc1b977374a5dc430a1ebb0ea0488205546d4678d6e31cc5f6801", size = 7595, upload-time = "2021-02-25T19:22:31.944Z" }, +] + [[package]] name = "html5rdf" version = "1.2.1" @@ -921,6 +955,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, ] +[[package]] +name = "json-flattener" +version = "0.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6d/77/b00e46d904818826275661a690532d3a3a43a4ded0264b2d7fcdb5c0feea/json_flattener-0.1.9.tar.gz", hash = "sha256:84cf8523045ffb124301a602602201665fcb003a171ece87e6f46ed02f7f0c15", size = 11479, upload-time = "2022-02-26T01:36:04.545Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/00/cc/7fbd75d3362e939eb98bcf9bd22f3f7df8c237a85148899ed3d38e5614e5/json_flattener-0.1.9-py3-none-any.whl", hash = "sha256:6b027746f08bf37a75270f30c6690c7149d5f704d8af1740c346a3a1236bc941", size = 10799, upload-time = "2022-02-26T01:36:03.06Z" }, +] + [[package]] name = "json5" version = "0.12.0" @@ -930,6 +977,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/9f/3500910d5a98549e3098807493851eeef2b89cdd3032227558a104dfe926/json5-0.12.0-py3-none-any.whl", hash = "sha256:6d37aa6c08b0609f16e1ec5ff94697e2cbbfbad5ac112afa05794da9ab7810db", size = 36079, upload-time = "2025-04-03T16:33:11.927Z" }, ] +[[package]] +name = "jsonasobj2" +version = "1.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hbreader" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/67/3a/feb245b755f7a47a0df4f30be645e8485d15ff13d0c95e018e4505a8811f/jsonasobj2-1.0.4.tar.gz", hash = "sha256:f50b1668ef478004aa487b2d2d094c304e5cb6b79337809f4a1f2975cc7fbb4e", size = 95522, upload-time = "2021-06-02T17:43:28.39Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/90/0d93963711f811efe528e3cead2f2bfb78c196df74d8a24fe8d655288e50/jsonasobj2-1.0.4-py3-none-any.whl", hash = "sha256:12e86f86324d54fcf60632db94ea74488d5314e3da554c994fe1e2c6f29acb79", size = 6324, upload-time = "2021-06-02T17:43:27.126Z" }, +] + [[package]] name = "jsonpointer" version = "3.0.0" @@ -1212,6 +1271,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/00/d90b10b962b4277f5e64a78b6609968859ff86889f5b898c1a778c06ec00/lark-1.2.2-py3-none-any.whl", hash = "sha256:c2276486b02f0f1b90be155f2c8ba4a8e194d42775786db622faccd652d8e80c", size = 111036, upload-time = "2024-08-13T19:48:58.603Z" }, ] +[[package]] +name = "linkml-runtime" +version = "1.10.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "curies" }, + { name = "deprecated" }, + { name = "hbreader" }, + { name = "json-flattener" }, + { name = "jsonasobj2" }, + { name = "jsonschema" }, + { name = "prefixcommons" }, + { name = "prefixmaps" }, + { name = "pydantic" }, + { name = "pyyaml" }, + { name = "rdflib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/3d/031e2e8ef7ca872340fb7b7102cfd1fe4a0b329dc04ff694ad9ec6ccaddc/linkml_runtime-1.10.0.tar.gz", hash = "sha256:899889d584ce8056c5c44512b2d247bdc84a8484c3aa228aeb2db283e3a9d2ec", size = 589957, upload-time = "2026-02-25T17:13:34.034Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/2d/6ab4127bb9aa6e3b54ad5d81fd0c0c12e4d1a80300f2bfdac8f2e18f9d17/linkml_runtime-1.10.0-py3-none-any.whl", hash = "sha256:b7caf806e1b49bf62005d8f398b070c282742c5f6626469fdc1660add0c9da58", size = 584001, upload-time = "2026-02-25T17:13:30.603Z" }, +] + [[package]] name = "marisa-trie" version = "1.2.1" @@ -1629,6 +1712,34 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, ] +[[package]] +name = "prefixcommons" +version = "0.1.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "pytest-logging" }, + { name = "pyyaml" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/b5/c5b63a4bf5dedb36567181fdb98dbcc7aaa025faebabaaffa2f5eb4b8feb/prefixcommons-0.1.12.tar.gz", hash = "sha256:22c4e2d37b63487b3ab48f0495b70f14564cb346a15220f23919eb0c1851f69f", size = 24063, upload-time = "2022-07-19T00:06:12.478Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/31/e8/715b09df3dab02b07809d812042dc47a46236b5603d9d3a2572dbd1d8a97/prefixcommons-0.1.12-py3-none-any.whl", hash = "sha256:16dbc0a1f775e003c724f19a694fcfa3174608f5c8b0e893d494cf8098ac7f8b", size = 29482, upload-time = "2022-07-19T00:06:08.709Z" }, +] + +[[package]] +name = "prefixmaps" +version = "0.2.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "curies" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4d/cf/f588bcdfd2c841839b9d59ce219a46695da56aa2805faff937bbafb9ee2b/prefixmaps-0.2.6.tar.gz", hash = "sha256:7421e1244eea610217fa1ba96c9aebd64e8162a930dc0626207cd8bf62ecf4b9", size = 709899, upload-time = "2024-10-17T16:30:57.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/b2/2b2153173f2819e3d7d1949918612981bc6bd895b75ffa392d63d115f327/prefixmaps-0.2.6-py3-none-any.whl", hash = "sha256:f6cef28a7320fc6337cf411be212948ce570333a0ce958940ef684c7fb192a62", size = 754732, upload-time = "2024-10-17T16:30:55.731Z" }, +] + [[package]] name = "prettytable" version = "3.16.0" @@ -1879,6 +1990,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bc/16/4ea354101abb1287856baa4af2732be351c7bee728065aed451b678153fd/pytest_cov-6.2.1-py3-none-any.whl", hash = "sha256:f5bc4c23f42f1cdd23c70b1dab1bbaef4fc505ba950d53e0081d0730dd7e86d5", size = 24644, upload-time = "2025-06-12T10:47:45.932Z" }, ] +[[package]] +name = "pytest-logging" +version = "2015.11.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/1e/fb11174c9eaebcec27d36e9e994b90ffa168bc3226925900b9dbbf16c9da/pytest-logging-2015.11.4.tar.gz", hash = "sha256:cec5c85ecf18aab7b2ead5498a31b9f758680ef5a902b9054ab3f2bdbb77c896", size = 3916, upload-time = "2015-11-04T12:15:54.122Z" } + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2289,6 +2409,7 @@ dependencies = [ { name = "inflect" }, { name = "jinja2" }, { name = "langcodes" }, + { name = "linkml-runtime" }, { name = "pydantic" }, { name = "pyshacl" }, { name = "pyyaml" }, @@ -2327,6 +2448,7 @@ requires-dist = [ { name = "inflect", specifier = ">=7.0.0" }, { name = "jinja2", specifier = ">=3.1.0" }, { name = "langcodes", specifier = ">=3.5.0" }, + { name = "linkml-runtime", specifier = ">=1.10.0" }, { name = "pydantic", specifier = ">=2.10.6" }, { name = "pyshacl", specifier = ">=0.30.0" }, { name = "pyyaml", specifier = ">=6.0.2" }, @@ -2885,6 +3007,69 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ca/51/5447876806d1088a0f8f71e16542bf350918128d0a69437df26047c8e46f/widgetsnbextension-4.0.14-py3-none-any.whl", hash = "sha256:4875a9eaf72fbf5079dc372a51a9f268fc38d46f767cbf85c43a36da5cb9b575", size = 2196503, upload-time = "2025-04-10T13:01:23.086Z" }, ] +[[package]] +name = "wrapt" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/37/ae31f40bec90de2f88d9597d0b5281e23ffe85b893a47ca5d9c05c63a4f6/wrapt-2.1.1.tar.gz", hash = "sha256:5fdcb09bf6db023d88f312bd0767594b414655d58090fc1c46b3414415f67fac", size = 81329, upload-time = "2026-02-03T02:12:13.786Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b8/a8/9254e4da74b30a105935197015b18b31b7a298bf046e67d8952ef74967bd/wrapt-2.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6c366434a7fb914c7a5de508ed735ef9c133367114e1a7cb91dfb5cd806a1549", size = 60554, upload-time = "2026-02-03T02:11:13.038Z" }, + { url = "https://files.pythonhosted.org/packages/9e/a1/378579880cc7af226354054a2c255f69615b379d8adad482bfe2f22a0dc2/wrapt-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d6a2068bd2e1e19e5a317c8c0b288267eec4e7347c36bc68a6e378a39f19ee7", size = 61491, upload-time = "2026-02-03T02:12:56.077Z" }, + { url = "https://files.pythonhosted.org/packages/dc/72/957b51c56acca35701665878ad31626182199fc4afecfe67dea072210f95/wrapt-2.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:891ab4713419217b2aed7dd106c9200f64e6a82226775a0d2ebd6bef2ebd1747", size = 113949, upload-time = "2026-02-03T02:11:04.516Z" }, + { url = "https://files.pythonhosted.org/packages/cd/74/36bbebb4a3d2ae9c3e6929639721f8606cd0710a82a777c371aa69e36504/wrapt-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8ef36a0df38d2dc9d907f6617f89e113c5892e0a35f58f45f75901af0ce7d81", size = 115989, upload-time = "2026-02-03T02:12:19.398Z" }, + { url = "https://files.pythonhosted.org/packages/ae/0d/f1177245a083c7be284bc90bddfe5aece32cdd5b858049cb69ce001a0e8d/wrapt-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:76e9af3ebd86f19973143d4d592cbf3e970cf3f66ddee30b16278c26ae34b8ab", size = 115242, upload-time = "2026-02-03T02:11:08.111Z" }, + { url = "https://files.pythonhosted.org/packages/62/3e/3b7cf5da27e59df61b1eae2d07dd03ff5d6f75b5408d694873cca7a8e33c/wrapt-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ff562067485ebdeaef2fa3fe9b1876bc4e7b73762e0a01406ad81e2076edcebf", size = 113676, upload-time = "2026-02-03T02:12:41.026Z" }, + { url = "https://files.pythonhosted.org/packages/f7/65/8248d3912c705f2c66f81cb97c77436f37abcbedb16d633b5ab0d795d8cd/wrapt-2.1.1-cp311-cp311-win32.whl", hash = "sha256:9e60a30aa0909435ec4ea2a3c53e8e1b50ac9f640c0e9fe3f21fd248a22f06c5", size = 57863, upload-time = "2026-02-03T02:12:18.112Z" }, + { url = "https://files.pythonhosted.org/packages/6b/31/d29310ab335f71f00c50466153b3dc985aaf4a9fc03263e543e136859541/wrapt-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7d79954f51fcf84e5ec4878ab4aea32610d70145c5bbc84b3370eabfb1e096c2", size = 60224, upload-time = "2026-02-03T02:12:29.289Z" }, + { url = "https://files.pythonhosted.org/packages/0c/90/a6ec319affa6e2894962a0cb9d73c67f88af1a726d15314bfb5c88b8a08d/wrapt-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:d3ffc6b0efe79e08fd947605fd598515aebefe45e50432dc3b5cd437df8b1ada", size = 58643, upload-time = "2026-02-03T02:12:43.022Z" }, + { url = "https://files.pythonhosted.org/packages/df/cb/4d5255d19bbd12be7f8ee2c1fb4269dddec9cef777ef17174d357468efaa/wrapt-2.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab8e3793b239db021a18782a5823fcdea63b9fe75d0e340957f5828ef55fcc02", size = 61143, upload-time = "2026-02-03T02:11:46.313Z" }, + { url = "https://files.pythonhosted.org/packages/6f/07/7ed02daa35542023464e3c8b7cb937fa61f6c61c0361ecf8f5fecf8ad8da/wrapt-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7c0300007836373d1c2df105b40777986accb738053a92fe09b615a7a4547e9f", size = 61740, upload-time = "2026-02-03T02:12:51.966Z" }, + { url = "https://files.pythonhosted.org/packages/c4/60/a237a4e4a36f6d966061ccc9b017627d448161b19e0a3ab80a7c7c97f859/wrapt-2.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2b27c070fd1132ab23957bcd4ee3ba707a91e653a9268dc1afbd39b77b2799f7", size = 121327, upload-time = "2026-02-03T02:11:06.796Z" }, + { url = "https://files.pythonhosted.org/packages/ae/fe/9139058a3daa8818fc67e6460a2340e8bbcf3aef8b15d0301338bbe181ca/wrapt-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b0e36d845e8b6f50949b6b65fc6cd279f47a1944582ed4ec8258cd136d89a64", size = 122903, upload-time = "2026-02-03T02:12:48.657Z" }, + { url = "https://files.pythonhosted.org/packages/91/10/b8479202b4164649675846a531763531f0a6608339558b5a0a718fc49a8d/wrapt-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4aeea04a9889370fcfb1ef828c4cc583f36a875061505cd6cd9ba24d8b43cc36", size = 121333, upload-time = "2026-02-03T02:11:32.148Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/75fc793b791d79444aca2c03ccde64e8b99eda321b003f267d570b7b0985/wrapt-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d88b46bb0dce9f74b6817bc1758ff2125e1ca9e1377d62ea35b6896142ab6825", size = 120458, upload-time = "2026-02-03T02:11:16.039Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8f/c3f30d511082ca6d947c405f9d8f6c8eaf83cfde527c439ec2c9a30eb5ea/wrapt-2.1.1-cp312-cp312-win32.whl", hash = "sha256:63decff76ca685b5c557082dfbea865f3f5f6d45766a89bff8dc61d336348833", size = 58086, upload-time = "2026-02-03T02:12:35.041Z" }, + { url = "https://files.pythonhosted.org/packages/0a/c8/37625b643eea2849f10c3b90f69c7462faa4134448d4443234adaf122ae5/wrapt-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b828235d26c1e35aca4107039802ae4b1411be0fe0367dd5b7e4d90e562fcbcd", size = 60328, upload-time = "2026-02-03T02:12:45.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/79/56242f07572d5682ba8065a9d4d9c2218313f576e3c3471873c2a5355ffd/wrapt-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:75128507413a9f1bcbe2db88fd18fbdbf80f264b82fa33a6996cdeaf01c52352", size = 58722, upload-time = "2026-02-03T02:12:27.949Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3cf290212855b19af9fcc41b725b5620b32f470d6aad970c2593500817eb/wrapt-2.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ce9646e17fa7c3e2e7a87e696c7de66512c2b4f789a8db95c613588985a2e139", size = 61150, upload-time = "2026-02-03T02:12:50.575Z" }, + { url = "https://files.pythonhosted.org/packages/9d/33/5b8f89a82a9859ce82da4870c799ad11ce15648b6e1c820fec3e23f4a19f/wrapt-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:428cfc801925454395aa468ba7ddb3ed63dc0d881df7b81626cdd433b4e2b11b", size = 61743, upload-time = "2026-02-03T02:11:55.733Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2f/60c51304fbdf47ce992d9eefa61fbd2c0e64feee60aaa439baf42ea6f40b/wrapt-2.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5797f65e4d58065a49088c3b32af5410751cd485e83ba89e5a45e2aa8905af98", size = 121341, upload-time = "2026-02-03T02:11:20.461Z" }, + { url = "https://files.pythonhosted.org/packages/ad/03/ce5256e66dd94e521ad5e753c78185c01b6eddbed3147be541f4d38c0cb7/wrapt-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a2db44a71202c5ae4bb5f27c6d3afbc5b23053f2e7e78aa29704541b5dad789", size = 122947, upload-time = "2026-02-03T02:11:33.596Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ae/50ca8854b81b946a11a36fcd6ead32336e6db2c14b6e4a8b092b80741178/wrapt-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8d5350c3590af09c1703dd60ec78a7370c0186e11eaafb9dda025a30eee6492d", size = 121370, upload-time = "2026-02-03T02:11:09.886Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d9/d6a7c654e0043319b4cc137a4caaf7aa16b46b51ee8df98d1060254705b7/wrapt-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2d9b076411bed964e752c01b49fd224cc385f3a96f520c797d38412d70d08359", size = 120465, upload-time = "2026-02-03T02:11:37.592Z" }, + { url = "https://files.pythonhosted.org/packages/55/90/65be41e40845d951f714b5a77e84f377a3787b1e8eee6555a680da6d0db5/wrapt-2.1.1-cp313-cp313-win32.whl", hash = "sha256:0bb7207130ce6486727baa85373503bf3334cc28016f6928a0fa7e19d7ecdc06", size = 58090, upload-time = "2026-02-03T02:12:53.342Z" }, + { url = "https://files.pythonhosted.org/packages/5f/66/6a09e0294c4fc8c26028a03a15191721c9271672467cc33e6617ee0d91d2/wrapt-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:cbfee35c711046b15147b0ae7db9b976f01c9520e6636d992cd9e69e5e2b03b1", size = 60341, upload-time = "2026-02-03T02:12:36.384Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f0/20ceb8b701e9a71555c87a5ddecbed76ec16742cf1e4b87bbaf26735f998/wrapt-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:7d2756061022aebbf57ba14af9c16e8044e055c22d38de7bf40d92b565ecd2b0", size = 58731, upload-time = "2026-02-03T02:12:01.328Z" }, + { url = "https://files.pythonhosted.org/packages/80/b4/fe95beb8946700b3db371f6ce25115217e7075ca063663b8cca2888ba55c/wrapt-2.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4814a3e58bc6971e46baa910ecee69699110a2bf06c201e24277c65115a20c20", size = 62969, upload-time = "2026-02-03T02:11:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/b8/89/477b0bdc784e3299edf69c279697372b8bd4c31d9c6966eae405442899df/wrapt-2.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:106c5123232ab9b9f4903692e1fa0bdc231510098f04c13c3081f8ad71c3d612", size = 63606, upload-time = "2026-02-03T02:12:02.64Z" }, + { url = "https://files.pythonhosted.org/packages/ed/55/9d0c1269ab76de87715b3b905df54dd25d55bbffd0b98696893eb613469f/wrapt-2.1.1-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1a40b83ff2535e6e56f190aff123821eea89a24c589f7af33413b9c19eb2c738", size = 152536, upload-time = "2026-02-03T02:11:24.492Z" }, + { url = "https://files.pythonhosted.org/packages/44/18/2004766030462f79ad86efaa62000b5e39b1ff001dcce86650e1625f40ae/wrapt-2.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:789cea26e740d71cf1882e3a42bb29052bc4ada15770c90072cb47bf73fb3dbf", size = 158697, upload-time = "2026-02-03T02:12:32.214Z" }, + { url = "https://files.pythonhosted.org/packages/e1/bb/0a880fa0f35e94ee843df4ee4dd52a699c9263f36881311cfb412c09c3e5/wrapt-2.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:ba49c14222d5e5c0ee394495a8655e991dc06cbca5398153aefa5ac08cd6ccd7", size = 155563, upload-time = "2026-02-03T02:11:49.737Z" }, + { url = "https://files.pythonhosted.org/packages/42/ff/cd1b7c4846c8678fac359a6eb975dc7ab5bd606030adb22acc8b4a9f53f1/wrapt-2.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ac8cda531fe55be838a17c62c806824472bb962b3afa47ecbd59b27b78496f4e", size = 150161, upload-time = "2026-02-03T02:12:33.613Z" }, + { url = "https://files.pythonhosted.org/packages/38/ec/67c90a7082f452964b4621e4890e9a490f1add23cdeb7483cc1706743291/wrapt-2.1.1-cp313-cp313t-win32.whl", hash = "sha256:b8af75fe20d381dd5bcc9db2e86a86d7fcfbf615383a7147b85da97c1182225b", size = 59783, upload-time = "2026-02-03T02:11:39.863Z" }, + { url = "https://files.pythonhosted.org/packages/ec/08/466afe4855847d8febdfa2c57c87e991fc5820afbdef01a273683dfd15a0/wrapt-2.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:45c5631c9b6c792b78be2d7352129f776dd72c605be2c3a4e9be346be8376d83", size = 63082, upload-time = "2026-02-03T02:12:09.075Z" }, + { url = "https://files.pythonhosted.org/packages/9a/62/60b629463c28b15b1eeadb3a0691e17568622b12aa5bfa7ebe9b514bfbeb/wrapt-2.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:da815b9263947ac98d088b6414ac83507809a1d385e4632d9489867228d6d81c", size = 60251, upload-time = "2026-02-03T02:11:21.794Z" }, + { url = "https://files.pythonhosted.org/packages/95/a0/1c2396e272f91efe6b16a6a8bce7ad53856c8f9ae4f34ceaa711d63ec9e1/wrapt-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aa1765054245bb01a37f615503290d4e207e3fd59226e78341afb587e9c1236", size = 61311, upload-time = "2026-02-03T02:12:44.41Z" }, + { url = "https://files.pythonhosted.org/packages/b0/9a/d2faba7e61072a7507b5722db63562fdb22f5a24e237d460d18755627f15/wrapt-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:feff14b63a6d86c1eee33a57f77573649f2550935981625be7ff3cb7342efe05", size = 61805, upload-time = "2026-02-03T02:11:59.905Z" }, + { url = "https://files.pythonhosted.org/packages/db/56/073989deb4b5d7d6e7ea424476a4ae4bda02140f2dbeaafb14ba4864dd60/wrapt-2.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81fc5f22d5fcfdbabde96bb3f5379b9f4476d05c6d524d7259dc5dfb501d3281", size = 120308, upload-time = "2026-02-03T02:12:04.46Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b6/84f37261295e38167a29eb82affaf1dc15948dc416925fe2091beee8e4ac/wrapt-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:951b228ecf66def855d22e006ab9a1fc12535111ae7db2ec576c728f8ddb39e8", size = 122688, upload-time = "2026-02-03T02:11:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/ea/80/32db2eec6671f80c65b7ff175be61bc73d7f5223f6910b0c921bbc4bd11c/wrapt-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ddf582a95641b9a8c8bd643e83f34ecbbfe1b68bc3850093605e469ab680ae3", size = 121115, upload-time = "2026-02-03T02:12:39.068Z" }, + { url = "https://files.pythonhosted.org/packages/49/ef/dcd00383df0cd696614127902153bf067971a5aabcd3c9dcb2d8ef354b2a/wrapt-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:fc5c500966bf48913f795f1984704e6d452ba2414207b15e1f8c339a059d5b16", size = 119484, upload-time = "2026-02-03T02:11:48.419Z" }, + { url = "https://files.pythonhosted.org/packages/76/29/0630280cdd2bd8f86f35cb6854abee1c9d6d1a28a0c6b6417cd15d378325/wrapt-2.1.1-cp314-cp314-win32.whl", hash = "sha256:4aa4baadb1f94b71151b8e44a0c044f6af37396c3b8bcd474b78b49e2130a23b", size = 58514, upload-time = "2026-02-03T02:11:58.616Z" }, + { url = "https://files.pythonhosted.org/packages/db/19/5bed84f9089ed2065f6aeda5dfc4f043743f642bc871454b261c3d7d322b/wrapt-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:860e9d3fd81816a9f4e40812f28be4439ab01f260603c749d14be3c0a1170d19", size = 60763, upload-time = "2026-02-03T02:12:24.553Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/b967f2f9669e4249b4fe82e630d2a01bc6b9e362b9b12ed91bbe23ae8df4/wrapt-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:3c59e103017a2c1ea0ddf589cbefd63f91081d7ce9d491d69ff2512bb1157e23", size = 59051, upload-time = "2026-02-03T02:11:29.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/19/6fed62be29f97eb8a56aff236c3f960a4b4a86e8379dc7046a8005901a97/wrapt-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9fa7c7e1bee9278fc4f5dd8275bc8d25493281a8ec6c61959e37cc46acf02007", size = 63059, upload-time = "2026-02-03T02:12:06.368Z" }, + { url = "https://files.pythonhosted.org/packages/0a/1c/b757fd0adb53d91547ed8fad76ba14a5932d83dde4c994846a2804596378/wrapt-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:39c35e12e8215628984248bd9c8897ce0a474be2a773db207eb93414219d8469", size = 63618, upload-time = "2026-02-03T02:12:23.197Z" }, + { url = "https://files.pythonhosted.org/packages/10/fe/e5ae17b1480957c7988d991b93df9f2425fc51f128cf88144d6a18d0eb12/wrapt-2.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:94ded4540cac9125eaa8ddf5f651a7ec0da6f5b9f248fe0347b597098f8ec14c", size = 152544, upload-time = "2026-02-03T02:11:43.915Z" }, + { url = "https://files.pythonhosted.org/packages/3e/cc/99aed210c6b547b8a6e4cb9d1425e4466727158a6aeb833aa7997e9e08dd/wrapt-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da0af328373f97ed9bdfea24549ac1b944096a5a71b30e41c9b8b53ab3eec04a", size = 158700, upload-time = "2026-02-03T02:12:30.684Z" }, + { url = "https://files.pythonhosted.org/packages/81/0e/d442f745f4957944d5f8ad38bc3a96620bfff3562533b87e486e979f3d99/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4ad839b55f0bf235f8e337ce060572d7a06592592f600f3a3029168e838469d3", size = 155561, upload-time = "2026-02-03T02:11:28.164Z" }, + { url = "https://files.pythonhosted.org/packages/51/ac/9891816280e0018c48f8dfd61b136af7b0dcb4a088895db2531acde5631b/wrapt-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0d89c49356e5e2a50fa86b40e0510082abcd0530f926cbd71cf25bee6b9d82d7", size = 150188, upload-time = "2026-02-03T02:11:57.053Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/e2f273b6d70d41f98d0739aa9a269d0b633684a5fb17b9229709375748d4/wrapt-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:f4c7dd22cf7f36aafe772f3d88656559205c3af1b7900adfccb70edeb0d2abc4", size = 60425, upload-time = "2026-02-03T02:11:35.007Z" }, + { url = "https://files.pythonhosted.org/packages/1e/06/b500bfc38a4f82d89f34a13069e748c82c5430d365d9e6b75afb3ab74457/wrapt-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f76bc12c583ab01e73ba0ea585465a41e48d968f6d1311b4daec4f8654e356e3", size = 63855, upload-time = "2026-02-03T02:12:15.47Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cc/5f6193c32166faee1d2a613f278608e6f3b95b96589d020f0088459c46c9/wrapt-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7ea74fc0bec172f1ae5f3505b6655c541786a5cabe4bbc0d9723a56ac32eb9b9", size = 60443, upload-time = "2026-02-03T02:11:30.869Z" }, + { url = "https://files.pythonhosted.org/packages/c4/da/5a086bf4c22a41995312db104ec2ffeee2cf6accca9faaee5315c790377d/wrapt-2.1.1-py3-none-any.whl", hash = "sha256:3b0f4629eb954394a3d7c7a1c8cca25f0b07cefe6aa8545e862e9778152de5b7", size = 43886, upload-time = "2026-02-03T02:11:45.048Z" }, +] + [[package]] name = "zipp" version = "3.23.0"