From 8d866e849c4d842ef7873c95ad9e0ce6ea462aa5 Mon Sep 17 00:00:00 2001 From: Silvano Cirujano Cuesta Date: Fri, 2 Oct 2026 10:07:26 +0200 Subject: [PATCH 01/30] fix(github-templates): clean-up duplicated frontmatter "Feature request" and "Bug" GitHub issue templates had duplicated frontmatters. This patch removes the duplicates. Signed-off-by: Silvano Cirujano Cuesta --- .github/ISSUE_TEMPLATE/bug_report.md | 10 ---------- .github/ISSUE_TEMPLATE/feature_request.md | 10 ---------- 2 files changed, 20 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index cfede5c3b5..4e33f1513b 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -4,16 +4,6 @@ about: Create a report to help us improve title: '' labels: bug assignees: '' - ---- - ---- -name: Bug report -about: Create a report to help us improve -title: '' -labels: bug -assignees: '' - --- **Describe the bug** diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index e0f3f89ae7..270e88779a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -4,16 +4,6 @@ about: Suggest an idea for this project title: '' labels: feature assignees: '' - ---- - ---- -name: Feature request -about: Suggest an idea for this project -title: '' -labels: feature -assignees: '' - --- **What is your feature request?** From b2ebb170114e0245f761fa5feff86efc7e569a9b Mon Sep 17 00:00:00 2001 From: N <13322818+noelmcloughlin@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:59:33 +0100 Subject: [PATCH 02/30] feat(generator): shared config-file overlay for all generators (part 1) (#3973) * fix(projectgen): fix substring matching; check top keys/dirs are str before setattr * fix(projectgen): merge generator_args with cli_args; test empty config file * feat(generator): shared config-file overlay for golang/java generators --- .../linkml/generators/golanggen/golanggen.py | 39 +++-- .../linkml/src/linkml/generators/javagen.py | 30 +++- packages/linkml/src/linkml/utils/generator.py | 49 +++++- tests/linkml/test_generators/test_javagen.py | 60 +++++++- tests/linkml/test_utils/test_generator.py | 142 +++++++++++++++++- 5 files changed, 296 insertions(+), 24 deletions(-) diff --git a/packages/linkml/src/linkml/generators/golanggen/golanggen.py b/packages/linkml/src/linkml/generators/golanggen/golanggen.py index 351b5e89c6..61c6383ba3 100644 --- a/packages/linkml/src/linkml/generators/golanggen/golanggen.py +++ b/packages/linkml/src/linkml/generators/golanggen/golanggen.py @@ -28,7 +28,7 @@ ) from linkml.generators.oocodegen import PACKAGE, OOCodeGenerator from linkml.utils.deprecation import deprecated_fields, deprecation_warning -from linkml.utils.generator import read_generator_config, shared_arguments +from linkml.utils.generator import apply_config_defaults, read_generator_config, shared_arguments from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, SlotDefinition from linkml_runtime.utils.formatutils import camelcase, underscore from linkml_runtime.utils.schemaview import SchemaView @@ -168,6 +168,7 @@ class GolangGenerator(OOCodeGenerator): generatorversion = "0.2.0" valid_formats = ["go", "golang"] file_extension = "go" + config_section_name = "golang" # ObjectVars package: PACKAGE | None = None @@ -712,9 +713,10 @@ def _template_environment(self) -> Environment: "--config-file", "-C", type=click.File("rb"), - help="Path to a gen-project-style YAML config file setting " - "'generator_args: {golang: {package: ...}}'. An explicit --package always " - "takes precedence over the config file.", + help="Path to a YAML config file supplying defaults under " + "'generator_args: {golang: {package: ...}}'. Keys are this command's own option " + "names with dashes as underscores; explicit command-line options always take " + "precedence over the config file.", ) @click.option( "--alphabetical-sort/--no-alphabetical-sort", @@ -753,10 +755,10 @@ def _template_environment(self) -> Environment: ) @click.version_option(__version__, "-V", "--version") @click.command(name="golang") +@click.pass_context def cli( + ctx, yamlfile, - package: str | None = None, - package_name: str | None = None, config_file=None, alphabetical_sort: bool = False, nullable_primitives: bool = True, @@ -773,13 +775,27 @@ def cli( - JSON tags for serialization - Struct embedding for inheritance """ + # --package / --package-name consumed from **args; fold the deprecated alias before overlay + package_name = args.pop("package_name", None) + config = read_generator_config(config_file, GolangGenerator.config_section_name) if package_name is not None: deprecation_warning("golanggen-package-name-option") - if package is None: - package = package_name - if package is None: - package = read_generator_config(config_file, "golang").get("package") - GolangGenerator.validate_generator_args({"package": package}) + if args.get("package") is None: + args["package"] = package_name + # a deprecated --package-name outranks a config-file package + config = {k: v for k, v in config.items() if k != "package"} + apply_config_defaults(ctx, config, args) + # a config-file-supplied deprecated `package_name` folds same as --package-name + if args.get("package_name") is not None and args.get("package") is None: + deprecation_warning("golanggen-package-name-option") + args["package"] = args.pop("package_name") + args.pop("package_name", None) + GolangGenerator.validate_generator_args(args) + # rebind named locals so overlaid config values are honored and never collide with **args below + alphabetical_sort = args.pop("alphabetical_sort", alphabetical_sort) + nullable_primitives = args.pop("nullable_primitives", nullable_primitives) + named_slot_types = args.pop("named_slot_types", named_slot_types) + template_dir = args.pop("template_dir", template_dir) if template_dir is not None: if not Path(template_dir).exists(): @@ -791,7 +807,6 @@ def cli( # its own traceback rather than being caught and mistaken for one. gen = GolangGenerator( yamlfile, - package=package, alphabetical_sort=alphabetical_sort, nullable_primitives=nullable_primitives, named_slot_types=named_slot_types, diff --git a/packages/linkml/src/linkml/generators/javagen.py b/packages/linkml/src/linkml/generators/javagen.py index e7620691d7..2675ba3045 100644 --- a/packages/linkml/src/linkml/generators/javagen.py +++ b/packages/linkml/src/linkml/generators/javagen.py @@ -11,7 +11,7 @@ from linkml._version import __version__ from linkml.generators.oocodegen import OOCodeGenerator, OODocument from linkml.utils.deprecation import deprecated_fields, deprecation_warning -from linkml.utils.generator import read_generator_config, shared_arguments +from linkml.utils.generator import apply_config_defaults, read_generator_config, shared_arguments from linkml_runtime import SchemaView from linkml_runtime.linkml_model.meta import ClassDefinition, SlotDefinition, TypeDefinition from linkml_runtime.utils.formatutils import camelcase @@ -284,6 +284,7 @@ class JavaGenerator(OOCodeGenerator): generatorversion = "0.0.1" valid_formats = ["java"] file_extension = "java" + config_section_name = "java" # ObjectVars template_file: str | None = None @@ -600,8 +601,9 @@ def needs_extra_slots(self, klass: ClassDefinition) -> bool: "--config-file", "-C", type=click.File("rb"), - help="Path to a gen-project-style YAML config file setting " - "'generator_args: {java: {package: ...}}'. An explicit --package always takes " + help="Path to a YAML config file supplying defaults under " + "'generator_args: {java: {package: ...}}'. Keys are this command's own option " + "names with dashes as underscores; explicit command-line options always take " "precedence over the config file.", ) @click.option( @@ -627,10 +629,11 @@ def needs_extra_slots(self, klass: ClassDefinition) -> bool: @click.option("--use-aliases/--no-use-aliases", default=False, help="Use aliases when available to name fields") @click.version_option(__version__, "-V", "--version") @click.command(name="java") +@click.pass_context def cli( + ctx, yamlfile, output_directory=None, - package=None, config_file=None, template_dir=None, template_variant=None, @@ -646,9 +649,20 @@ def cli( **args, ): """Generate java classes to represent a LinkML model""" - if package is None: - package = read_generator_config(config_file, "java").get("package") - JavaGenerator.validate_generator_args({"package": package}) + # --package now consumed from **args so config overlay can fill, and it reaches the generator + config = read_generator_config(config_file, JavaGenerator.config_section_name) + apply_config_defaults(ctx, config, args) + JavaGenerator.validate_generator_args(args) + # rebind named locals so overlaid config values are honored and never collide with **args below + output_directory = args.pop("output_directory", output_directory) + template_dir = args.pop("template_dir", template_dir) + template_variant = args.pop("template_variant", template_variant) + template_file = args.pop("template_file", template_file) + generate_records = args.pop("generate_records", generate_records) + true_enums = args.pop("true_enums", true_enums) + use_aliases = args.pop("use_aliases", use_aliases) + extra_template = args.pop("extra_template", extra_template) + visitor = args.pop("visitor", visitor) if generate_records: template_variant = "records" if template_file is not None: @@ -667,6 +681,7 @@ def cli( args["metadata"] = head if yamlfile.is_dir(): + args.pop("package", None) # inferred per schema from the directory layout # Generate code for all root schemas under the specified directory, # inferring the package name from the directory hierarchy schemas: list[tuple[Path, Path, str]] = [] @@ -700,7 +715,6 @@ def cli( # its own traceback rather than being caught and mistaken for one. generator = JavaGenerator( yamlfile, - package=package, template_dir=template_dir, template_file=template_file, true_enums=true_enums, diff --git a/packages/linkml/src/linkml/utils/generator.py b/packages/linkml/src/linkml/utils/generator.py index dc338941e4..8ea9e28da9 100644 --- a/packages/linkml/src/linkml/utils/generator.py +++ b/packages/linkml/src/linkml/utils/generator.py @@ -30,7 +30,7 @@ import click import yaml -from click import Argument, Command, Option +from click import Argument, Command, Option, ParameterSource from linkml import LOCAL_METAMODEL_YAML_FILE from linkml.cli.logging import DEFAULT_LOG_LEVEL_INT, log_level_option @@ -99,6 +99,9 @@ class Generator(metaclass=abc.ABCMeta): generatorversion: ClassVar[str] = None # Generator version identifier """Version of the generator. Consider deprecating and instead use overall linkml version""" + config_section_name: ClassVar[str] = None + """Section this generator reads from a ``--config-file`` (``generator_args.``).""" + uses_schemaloader: ClassVar[bool] = True """Old-style generator that uses the SchemaLoader and visitor pattern""" @@ -1188,3 +1191,47 @@ def read_generator_config(config_file: IO[bytes] | None, generator_name: str) -> return config_mapping(generator_args.get(generator_name), f"'generator_args.{generator_name}'", source) except ValueError as e: raise click.UsageError(str(e)) from e + + +# Options whose click callbacks fire at parse time (e.g. configuring logging); a +# config-file value would be recorded but never trigger the callback, so these are +# honored only from the command line, never overlaid from a config file. +_CONFIG_OVERLAY_SKIP = frozenset({"yamlfile", "schema", "verbose", "log_level", "stacktrace", "config_file"}) + + +def apply_config_defaults(ctx: click.Context, config: Mapping[str, Any], args: dict[str, Any]) -> dict[str, Any]: + """Overlay ``--config-file`` values onto CLI args the user did not set. + + Precedence is command line/env over config over option defaults: a value is + taken from ``config`` only when its option was left at the default. Values are + converted by their option's click type, so a config file and a command line + reach the generator identically. Keys that are not options of this command are + reported per-key (a typo in the config file) rather than silently dropped. + + :param ctx: The active click context, used to tell which options the user set. + :param config: One generator's settings, e.g. from :func:`read_generator_config`. + :param args: The keyword args forwarded to the generator; updated in place. + :return: ``args``, updated in place. + :raises click.BadParameter: If a value is not valid for its option's type. + """ + if not config: + return args + # expose_value=False params (--version, --help) never reach the callback, so a + # config key naming one is a typo, not something to overlay + params = {param.name: param for param in ctx.command.params if param.expose_value} + for key, value in config.items(): + param = params.get(key) + if param is None: + logger.warning(f"--config-file: ignoring unknown key {key!r}") + continue + if key in _CONFIG_OVERLAY_SKIP: + continue + # overlay only when the option was left at its default; CLI/env values already win + if ctx.get_parameter_source(key) in (ParameterSource.DEFAULT, ParameterSource.DEFAULT_MAP): + # a YAML scalar stands in for a one-element list on a repeatable option; + # click would otherwise iterate a string per character + if param.multiple and not isinstance(value, list | tuple): + value = [value] + # convert as click would, so e.g. `template_dir: /tmp` arrives as a Path + args[key] = param.type_cast_value(ctx, value) + return args diff --git a/tests/linkml/test_generators/test_javagen.py b/tests/linkml/test_generators/test_javagen.py index fee15e005b..1d0d5a3586 100644 --- a/tests/linkml/test_generators/test_javagen.py +++ b/tests/linkml/test_generators/test_javagen.py @@ -483,10 +483,66 @@ def test_cli_config_file_malformed_section_errors(tmp_path, config_yaml, where): assert f"expected a YAML mapping at {where}" in result.output +def test_cli_config_file_sets_a_repeatable_option(tmp_path): + """A repeatable option (`--visitor`) accepts a YAML scalar as a one-element list. + Treated as a bare string it would be iterated per character, generating a visitor + per letter instead of the one that was asked for.""" + schema_path = _write_minimal_schema(tmp_path / "pkg.yaml") + config_path = tmp_path / "myconfig.yaml" + config_path.write_text("generator_args:\n java:\n package: org.example\n visitor: Thing\n") + out_dir = tmp_path / "out" + + result = CliRunner().invoke( + cli, + ["--config-file", str(config_path), "--output-directory", str(out_dir), str(schema_path)], + ) + + assert result.exit_code == 0, result.output + assert (out_dir / "IThingVisitor.java").exists() + assert [p.name for p in out_dir.glob("I*Visitor.java")] == ["IThingVisitor.java"] + + +def test_cli_config_file_typed_option_is_converted(tmp_path): + """A config value for a `click.Path` option arrives as a Path, like it would from the + command line; as a raw str it reaches the generator and fails on the first Path call.""" + schema_path = _write_minimal_schema(tmp_path / "pkg.yaml") + template_dir = tmp_path / "templates" + template_dir.mkdir() + config_path = tmp_path / "myconfig.yaml" + config_path.write_text(f"generator_args:\n java:\n package: org.example\n template_dir: {template_dir}\n") + out_dir = tmp_path / "out" + + result = CliRunner().invoke( + cli, + ["--config-file", str(config_path), "--output-directory", str(out_dir), str(schema_path)], + ) + + assert result.exit_code == 0, result.output + assert_file_contains(out_dir / "Thing.java", "public class Thing", after="package org.example") + + +def test_cli_config_file_unknown_key_is_warned_not_injected(tmp_path, caplog): + """A key click never exposes (`version`, from --version) is a config typo: warn and + skip it, rather than passing a kwarg the generator's __init__ would reject.""" + schema_path = _write_minimal_schema(tmp_path / "pkg.yaml") + config_path = tmp_path / "myconfig.yaml" + config_path.write_text("generator_args:\n java:\n package: org.example\n version: '1.2'\n") + out_dir = tmp_path / "out" + + with caplog.at_level(logging.WARNING, logger="linkml.utils.generator"): + result = CliRunner().invoke( + cli, + ["--config-file", str(config_path), "--output-directory", str(out_dir), str(schema_path)], + ) + + assert result.exit_code == 0, result.output + assert any("version" in rec.getMessage() for rec in caplog.records) + + def test_cli_config_file_real_project_config_shape(tmp_path): """gen-java's --config-file accepts a full, real-world gen-project config.yaml - (other generators' sections, excludes/includes, etc.) and only reads - generator_args.java.package out of it, ignoring the rest.""" + (other generators' sections, excludes/includes, etc.), reading only its own + generator_args.java section and ignoring the other generators'.""" schema_path = _write_minimal_schema(tmp_path / "pkg.yaml") config_path = tmp_path / "config.yaml" config_path.write_text( diff --git a/tests/linkml/test_utils/test_generator.py b/tests/linkml/test_utils/test_generator.py index 94d96a1545..ed3b17e663 100644 --- a/tests/linkml/test_utils/test_generator.py +++ b/tests/linkml/test_utils/test_generator.py @@ -7,6 +7,7 @@ import re from dataclasses import dataclass, field from io import BytesIO, StringIO +from pathlib import Path from typing import cast import click @@ -14,7 +15,13 @@ from linkml import LOCAL_METAMODEL_YAML_FILE from linkml.generators.shaclgen import ShaclGenerator -from linkml.utils.generator import Generator, config_mapping, parse_config_yaml, read_generator_config +from linkml.utils.generator import ( + Generator, + apply_config_defaults, + config_mapping, + parse_config_yaml, + read_generator_config, +) from linkml_runtime import SchemaView from linkml_runtime.linkml_model.meta import ( ClassDefinition, @@ -969,3 +976,136 @@ def test_generation_date_suppression_does_not_mutate_callers_schema(): assert schema.generation_date == "2020-01-01T00:00:00" assert gen.schema is not schema assert gen.schema.generation_date is None + + +# ------------------------------------------------------------------------------ +# apply_config_defaults: the overlay a CLI runs after read_generator_config so a +# config file fills only options the user did not set on the command line. +# Uses a minimal throwaway click command so the tests exercise the real click +# ParameterSource machinery, not a mock of it. +# ------------------------------------------------------------------------------ + + +def _run_with_config(config, cli_args=()): + """Invoke a throwaway click command, returning its resolved kwargs and the click result.""" + from click.testing import CliRunner + + captured: dict = {} + + @click.command() + @click.option("--foo") + @click.option("--bar/--no-bar", default=False) + @click.option("--baz", multiple=True) + @click.option("--path-opt", type=click.Path(path_type=Path)) + @click.option("--fmt", type=click.Choice(["a", "b"])) + @click.option("--verbose", "-v", count=True) + @click.version_option("1.0") + @click.pass_context + def runner_cmd(ctx, **kwargs): + apply_config_defaults(ctx, config, kwargs) + captured.update(kwargs) + + return captured, CliRunner().invoke(runner_cmd, list(cli_args), standalone_mode=False) + + +def _invoke_with_config(config, cli_args=()): + """Invoke the throwaway command, asserting it succeeded, and return its resolved kwargs.""" + captured, result = _run_with_config(config, cli_args) + assert result.exception is None, result.output + return captured + + +def test_apply_config_defaults_overlays_when_option_is_at_default(): + """Config value wins when the user did not pass the option -- the CLI/env/default + precedence chain, without which config-file support would do nothing.""" + kwargs = _invoke_with_config({"foo": "from-config"}) + assert kwargs["foo"] == "from-config" + + +def test_apply_config_defaults_command_line_beats_config(): + """A value explicitly passed on the command line is never overwritten by the + config; this is the precedence guarantee documented in --config-file's help.""" + kwargs = _invoke_with_config({"foo": "from-config"}, cli_args=["--foo", "from-cli"]) + assert kwargs["foo"] == "from-cli" + + +def test_apply_config_defaults_unknown_key_warns_per_key(caplog): + """Each unknown key gets its own warning so a config-file typo is individually + obvious, rather than being silently dropped or collapsed into a single message.""" + with caplog.at_level(logging.WARNING, logger="linkml.utils.generator"): + kwargs = _invoke_with_config({"typo_one": 1, "typo_two": 2, "foo": "ok"}) + messages = [rec.getMessage() for rec in caplog.records] + assert any("typo_one" in m for m in messages) + assert any("typo_two" in m for m in messages) + assert kwargs["foo"] == "ok" + + +def test_apply_config_defaults_skips_parse_time_callback_options(): + """--verbose etc. run click callbacks at parse time; a config value would be + stored but never trigger the callback, so those options are honored only from + the command line to avoid a silently-ineffective config setting.""" + kwargs = _invoke_with_config({"verbose": 3}) + assert kwargs["verbose"] == 0 + + +def test_apply_config_defaults_empty_config_is_noop(): + """An empty (or missing) generator section leaves args untouched -- the common + case for a shared config file that only names some generators. ``ctx`` is never + dereferenced on this path, so passing ``None`` would crash if it were.""" + args = {"foo": None, "bar": False} + assert apply_config_defaults(None, {}, args) is args + assert args == {"foo": None, "bar": False} + + +def test_apply_config_defaults_converts_values_with_the_option_type(): + """A config value is cast by its option's click type, so `path_opt: /tmp` reaches the + generator as a Path -- the same object the command line yields, not a raw str that + blows up later on the first Path method call.""" + kwargs = _invoke_with_config({"path_opt": "/tmp"}) + assert kwargs["path_opt"] == Path("/tmp") + + +def test_apply_config_defaults_rejects_a_value_the_option_type_refuses(): + """An invalid config value fails as a click usage error, exactly as it would on the + command line, instead of reaching the generator and crashing there.""" + _, result = _run_with_config({"fmt": "nope"}) + assert isinstance(result.exception, click.BadParameter) + + +@pytest.mark.parametrize( + ("value", "expected"), + [("one", ("one",)), (["one", "two"], ("one", "two"))], +) +def test_apply_config_defaults_wraps_scalars_for_repeatable_options(value, expected): + """A repeatable option gets a tuple whether the YAML supplied a scalar or a list. + Without the scalar wrap, click iterates the string per character and the generator + silently acts on 'o', 'n', 'e' instead of 'one'.""" + kwargs = _invoke_with_config({"baz": value}) + assert kwargs["baz"] == expected + + +def test_apply_config_defaults_ignores_options_click_never_passes(caplog): + """`--version` is expose_value=False, so click never hands it to the callback; naming + it in a config file is a typo like any other, and overlaying it would inject a kwarg + the generator's __init__ cannot accept.""" + with caplog.at_level(logging.WARNING, logger="linkml.utils.generator"): + kwargs = _invoke_with_config({"version": "1.2"}) + assert "version" not in kwargs + assert any("version" in rec.getMessage() for rec in caplog.records) + + +def test_apply_config_defaults_returns_the_same_dict(): + """The helper mutates and returns the same dict, so callers can chain or ignore + the return value; the type contract mirrors ``dict.update``.""" + from click.testing import CliRunner + + @click.command() + @click.option("--foo") + @click.pass_context + def cmd(ctx, **kwargs): + args = {} + result = apply_config_defaults(ctx, {"foo": "x"}, args) + assert result is args + assert args == {"foo": "x"} + + assert CliRunner().invoke(cmd, [], standalone_mode=False).exception is None From 79ad3aca86fab840ce09cac6d1ae99e60d876ae0 Mon Sep 17 00:00:00 2001 From: Corey Cox <69321580+amc-corey-cox@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:07:27 -0500 Subject: [PATCH 03/30] Keep class_uri alongside its skos:exactMatch --- .../linkml/src/linkml/generators/jsonldgen.py | 3 +- .../linkml/src/linkml/utils/schemaloader.py | 1 - .../test_base/__snapshots__/annotations.json | 17 +- .../test_base/__snapshots__/annotations.ttl | 9 +- .../test_base/__snapshots__/extensions.json | 9 +- .../test_base/__snapshots__/extensions.ttl | 5 +- .../linkml/test_base/__snapshots__/meta.json | 170 +-- tests/linkml/test_base/__snapshots__/meta.ttl | 89 +- .../__snapshots__/biolink.json | 1012 +++++------------ .../enumeration/alternatives.yaml | 2 - .../linkml/test_generators/test_jsonldgen.py | 51 +- .../test_issues/__snapshots__/issue_163d.ttl | 3 +- .../test_issues/__snapshots__/issue_167.yaml | 4 - .../test_issues/__snapshots__/issue_167b.yaml | 12 - .../__snapshots__/issue_177_error.yaml | 6 - .../test_issues/__snapshots__/issue_18.yaml | 8 - .../__snapshots__/linkml_issue_384.other.txt | 17 +- .../__snapshots__/genjsonld/meta.json | 194 ++-- .../__snapshots__/genjsonld/meta.jsonld | 128 +-- .../genjsonld/simple_uri_test.jsonld | 10 +- .../__snapshots__/genyaml/clean.yaml | 4 - .../__snapshots__/genyaml/meta.yaml | 63 - .../__snapshots__/import_test_1.json | 12 - .../__snapshots__/multi_usages.yaml | 8 - .../__snapshots__/multi_usages_2.yaml | 8 - .../test_utils/__snapshots__/uriandcurie.json | 4 +- 26 files changed, 579 insertions(+), 1270 deletions(-) diff --git a/packages/linkml/src/linkml/generators/jsonldgen.py b/packages/linkml/src/linkml/generators/jsonldgen.py index 91759a3653..03a294759a 100644 --- a/packages/linkml/src/linkml/generators/jsonldgen.py +++ b/packages/linkml/src/linkml/generators/jsonldgen.py @@ -141,8 +141,7 @@ def adjust_slot(self, slot: SlotDefinition) -> None: def visit_class(self, cls: ClassDefinition) -> bool: self._visit(cls) - if hasattr(cls, "class_uri"): - delattr(cls, "class_uri") + cls.class_uri = self.namespaces.uri_for(cls.class_uri) # Slot usage is a construction artifact # TODO: Figure out why this is here. It isn't good form to alter a schema that may be used by other things cls.slot_usage = {} diff --git a/packages/linkml/src/linkml/utils/schemaloader.py b/packages/linkml/src/linkml/utils/schemaloader.py index 846e919c0e..3bb0e0aee9 100644 --- a/packages/linkml/src/linkml/utils/schemaloader.py +++ b/packages/linkml/src/linkml/utils/schemaloader.py @@ -299,7 +299,6 @@ def resolve(self) -> SchemaDefinition: self.schema_defaults.get(cls.from_schema, suffixed_cls_schema), camelcase(cls.name), ) - cls.exact_mappings.insert(0, cls.class_uri) # Get the inverse ducks all in a row before we start filling other stuff in for slot in self.schema.slots.values(): diff --git a/tests/linkml/test_base/__snapshots__/annotations.json b/tests/linkml/test_base/__snapshots__/annotations.json index b6c3c554bf..bc51cec7c6 100644 --- a/tests/linkml/test_base/__snapshots__/annotations.json +++ b/tests/linkml/test_base/__snapshots__/annotations.json @@ -417,14 +417,12 @@ "definition_uri": "https://w3id.org/linkml/Annotatable", "description": "mixin for classes that support annotations", "from_schema": "https://w3id.org/linkml/annotations", - "exact_mappings": [ - "linkml:Annotatable" - ], "mixin": true, "slots": [ "annotations" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Annotatable", "@type": "ClassDefinition" }, { @@ -432,9 +430,6 @@ "definition_uri": "https://w3id.org/linkml/Annotation", "description": "a tag/value pair with the semantics of OWL Annotation", "from_schema": "https://w3id.org/linkml/annotations", - "exact_mappings": [ - "linkml:Annotation" - ], "is_a": "Extension", "mixins": [ "Annotatable" @@ -446,6 +441,7 @@ "annotations" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Annotation", "@type": "ClassDefinition" }, { @@ -457,6 +453,7 @@ "linkml:Any" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any", "@type": "ClassDefinition" }, { @@ -465,15 +462,13 @@ "description": "a tag/value pair used to add non-model information to an entry", "from_schema": "https://w3id.org/linkml/extensions", "imported_from": "linkml:extensions", - "exact_mappings": [ - "linkml:Extension" - ], "slots": [ "extension_tag", "extension_value", "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extension", "@type": "ClassDefinition" }, { @@ -482,14 +477,12 @@ "description": "mixin for classes that support extension", "from_schema": "https://w3id.org/linkml/extensions", "imported_from": "linkml:extensions", - "exact_mappings": [ - "linkml:Extensible" - ], "mixin": true, "slots": [ "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extensible", "@type": "ClassDefinition" } ], diff --git a/tests/linkml/test_base/__snapshots__/annotations.ttl b/tests/linkml/test_base/__snapshots__/annotations.ttl index 34e75c8c31..6c58ada890 100644 --- a/tests/linkml/test_base/__snapshots__/annotations.ttl +++ b/tests/linkml/test_base/__snapshots__/annotations.ttl @@ -6,16 +6,16 @@ @prefix linkml: . @prefix pav: . linkml:Annotatable a linkml:ClassDefinition ; - skos:exactMatch "linkml:Annotatable"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/annotations"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Annotatable"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Annotatable"^^xsd:anyURI ; linkml:description "mixin for classes that support annotations" ; linkml:mixin true ; linkml:slot_usage _:c14n0 ; linkml:slots linkml:annotations . linkml:Annotation a linkml:ClassDefinition ; - skos:exactMatch "linkml:Annotation"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/annotations"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Annotation"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Annotation"^^xsd:anyURI ; linkml:description "a tag/value pair with the semantics of OWL Annotation" ; linkml:is_a linkml:Extension ; @@ -25,12 +25,13 @@ linkml:Annotation a linkml:ClassDefinition ; linkml:AnyValue a linkml:ClassDefinition ; skos:exactMatch "linkml:Any"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Any"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnyValue"^^xsd:anyURI ; linkml:imported_from "linkml:extensions" ; linkml:slot_usage _:c14n1 . linkml:Extensible a linkml:ClassDefinition ; - skos:exactMatch "linkml:Extensible"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Extensible"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Extensible"^^xsd:anyURI ; linkml:description "mixin for classes that support extension" ; linkml:imported_from "linkml:extensions" ; @@ -38,8 +39,8 @@ linkml:Extensible a linkml:ClassDefinition ; linkml:slot_usage _:c14n5 ; linkml:slots linkml:extensions . linkml:Extension a linkml:ClassDefinition ; - skos:exactMatch "linkml:Extension"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Extension"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Extension"^^xsd:anyURI ; linkml:description "a tag/value pair used to add non-model information to an entry" ; linkml:imported_from "linkml:extensions" ; diff --git a/tests/linkml/test_base/__snapshots__/extensions.json b/tests/linkml/test_base/__snapshots__/extensions.json index eaf4268fe4..f9604e0ea6 100644 --- a/tests/linkml/test_base/__snapshots__/extensions.json +++ b/tests/linkml/test_base/__snapshots__/extensions.json @@ -398,6 +398,7 @@ "linkml:Any" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any", "@type": "ClassDefinition" }, { @@ -405,15 +406,13 @@ "definition_uri": "https://w3id.org/linkml/Extension", "description": "a tag/value pair used to add non-model information to an entry", "from_schema": "https://w3id.org/linkml/extensions", - "exact_mappings": [ - "linkml:Extension" - ], "slots": [ "extension_tag", "extension_value", "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extension", "@type": "ClassDefinition" }, { @@ -421,14 +420,12 @@ "definition_uri": "https://w3id.org/linkml/Extensible", "description": "mixin for classes that support extension", "from_schema": "https://w3id.org/linkml/extensions", - "exact_mappings": [ - "linkml:Extensible" - ], "mixin": true, "slots": [ "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extensible", "@type": "ClassDefinition" } ], diff --git a/tests/linkml/test_base/__snapshots__/extensions.ttl b/tests/linkml/test_base/__snapshots__/extensions.ttl index c59fd71927..74f4a613d4 100644 --- a/tests/linkml/test_base/__snapshots__/extensions.ttl +++ b/tests/linkml/test_base/__snapshots__/extensions.ttl @@ -8,19 +8,20 @@ linkml:AnyValue a linkml:ClassDefinition ; skos:exactMatch "linkml:Any"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Any"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnyValue"^^xsd:anyURI ; linkml:slot_usage _:c14n0 . linkml:Extensible a linkml:ClassDefinition ; - skos:exactMatch "linkml:Extensible"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Extensible"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Extensible"^^xsd:anyURI ; linkml:description "mixin for classes that support extension" ; linkml:mixin true ; linkml:slot_usage _:c14n4 ; linkml:slots linkml:extensions . linkml:Extension a linkml:ClassDefinition ; - skos:exactMatch "linkml:Extension"^^xsd:anyURI ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Extension"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Extension"^^xsd:anyURI ; linkml:description "a tag/value pair used to add non-model information to an entry" ; linkml:slot_usage _:c14n3 ; diff --git a/tests/linkml/test_base/__snapshots__/meta.json b/tests/linkml/test_base/__snapshots__/meta.json index 594a2d4b90..acda6769d8 100644 --- a/tests/linkml/test_base/__snapshots__/meta.json +++ b/tests/linkml/test_base/__snapshots__/meta.json @@ -6805,6 +6805,7 @@ "linkml:Any" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any", "@type": "ClassDefinition" }, { @@ -6815,9 +6816,6 @@ "BasicSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:CommonMetadata" - ], "mixin": true, "slots": [ "description", @@ -6855,6 +6853,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/CommonMetadata", "@type": "ClassDefinition" }, { @@ -6872,9 +6871,6 @@ "data element", "object" ], - "exact_mappings": [ - "linkml:Element" - ], "abstract": true, "mixins": [ "Extensible", @@ -6927,6 +6923,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Element", "@type": "ClassDefinition" }, { @@ -6953,9 +6950,6 @@ "schema", "model" ], - "exact_mappings": [ - "linkml:SchemaDefinition" - ], "close_mappings": [ "qb:ComponentSet", "owl:Ontology" @@ -7030,6 +7024,7 @@ "schema_definition_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/SchemaDefinition", "tree_root": true, "@type": "ClassDefinition" }, @@ -7038,9 +7033,6 @@ "definition_uri": "https://w3id.org/linkml/TypeExpression", "description": "An abstract class grouping named types and anonymous type expressions", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:TypeExpression" - ], "is_a": "Expression", "mixin": true, "slots": [ @@ -7059,6 +7051,7 @@ "type_expression_all_of" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/TypeExpression", "@type": "ClassDefinition" }, { @@ -7066,9 +7059,6 @@ "definition_uri": "https://w3id.org/linkml/AnonymousTypeExpression", "description": "A type expression that is not a top-level named type definition. Used for nesting.", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:AnonymousTypeExpression" - ], "mixins": [ "TypeExpression" ], @@ -7088,6 +7078,7 @@ "type_expression_all_of" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/AnonymousTypeExpression", "@type": "ClassDefinition" }, { @@ -7100,9 +7091,6 @@ "OwlProfile" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:TypeDefinition" - ], "rank": 4, "is_a": "Element", "mixins": [ @@ -7172,6 +7160,7 @@ "type_expression_all_of" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/TypeDefinition", "@type": "ClassDefinition" }, { @@ -7183,9 +7172,6 @@ "BasicSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:SubsetDefinition" - ], "rank": 6, "is_a": "Element", "slots": [ @@ -7234,6 +7220,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/SubsetDefinition", "@type": "ClassDefinition" }, { @@ -7247,9 +7234,6 @@ "see_also": [ "https://en.wikipedia.org/wiki/Data_element_definition" ], - "exact_mappings": [ - "linkml:Definition" - ], "is_a": "Element", "abstract": true, "slots": [ @@ -7305,6 +7289,7 @@ "string_serialization" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Definition", "@type": "ClassDefinition" }, { @@ -7312,9 +7297,6 @@ "definition_uri": "https://w3id.org/linkml/EnumExpression", "description": "An expression that constrains the range of a slot", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:EnumExpression" - ], "is_a": "Expression", "slots": [ "code_set", @@ -7330,6 +7312,7 @@ "concepts" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/EnumExpression", "@type": "ClassDefinition" }, { @@ -7337,9 +7320,6 @@ "definition_uri": "https://w3id.org/linkml/AnonymousEnumExpression", "description": "An enum_expression that is not named", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:AnonymousEnumExpression" - ], "mixins": [ "EnumExpression" ], @@ -7357,6 +7337,7 @@ "concepts" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/AnonymousEnumExpression", "@type": "ClassDefinition" }, { @@ -7384,7 +7365,6 @@ "value domain" ], "exact_mappings": [ - "linkml:EnumDefinition", "qb:HierarchicalCodeList", "NCIT:C113497", "cdisc:ValueDomain" @@ -7462,6 +7442,7 @@ "concepts" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/EnumDefinition", "@type": "ClassDefinition" }, { @@ -7472,9 +7453,6 @@ "SpecificationSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:EnumBinding" - ], "mixins": [ "Extensible", "Annotatable", @@ -7522,6 +7500,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/EnumBinding", "@type": "ClassDefinition" }, { @@ -7532,14 +7511,12 @@ "SpecificationSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:MatchQuery" - ], "slots": [ "identifier_pattern", "source_ontology" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/MatchQuery", "@type": "ClassDefinition" }, { @@ -7550,9 +7527,6 @@ "SpecificationSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:ReachabilityQuery" - ], "slots": [ "source_ontology", "source_nodes", @@ -7562,6 +7536,7 @@ "traverse_up" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ReachabilityQuery", "@type": "ClassDefinition" }, { @@ -7619,6 +7594,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "http://www.w3.org/2008/05/skos-xl#Label", "@type": "ClassDefinition" }, { @@ -7626,12 +7602,10 @@ "definition_uri": "https://w3id.org/linkml/Expression", "description": "general mixin for any class that can represent some form of expression", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:Expression" - ], "abstract": true, "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Expression", "@type": "ClassDefinition" }, { @@ -7642,9 +7616,6 @@ "anonymous expressions are useful for when it is necessary to build a complex expression without introducing a named element for each sub-expression" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:AnonymousExpression" - ], "abstract": true, "mixins": [ "Expression", @@ -7690,6 +7661,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/AnonymousExpression", "@type": "ClassDefinition" }, { @@ -7697,9 +7669,6 @@ "definition_uri": "https://w3id.org/linkml/PathExpression", "description": "An expression that describes an abstract path from an object to another through a sequence of slot lookups", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:PathExpression" - ], "mixins": [ "Expression", "Extensible", @@ -7752,6 +7721,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/PathExpression", "@type": "ClassDefinition" }, { @@ -7759,9 +7729,6 @@ "definition_uri": "https://w3id.org/linkml/SlotExpression", "description": "an expression that constrains the range of values a slot can take", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:SlotExpression" - ], "is_a": "Expression", "mixin": true, "slots": [ @@ -7797,15 +7764,13 @@ "array" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/SlotExpression", "@type": "ClassDefinition" }, { "name": "AnonymousSlotExpression", "definition_uri": "https://w3id.org/linkml/AnonymousSlotExpression", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:AnonymousSlotExpression" - ], "is_a": "AnonymousExpression", "mixins": [ "SlotExpression" @@ -7878,6 +7843,7 @@ "array" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/AnonymousSlotExpression", "@type": "ClassDefinition" }, { @@ -7899,9 +7865,6 @@ "column", "variable" ], - "exact_mappings": [ - "linkml:SlotDefinition" - ], "close_mappings": [ "rdf:Property", "qb:ComponentProperty" @@ -8031,6 +7994,7 @@ "array" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/SlotDefinition", "@type": "ClassDefinition" }, { @@ -8038,9 +8002,6 @@ "definition_uri": "https://w3id.org/linkml/ClassExpression", "description": "A boolean expression that can be used to dynamically determine membership of a class", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:ClassExpression" - ], "mixin": true, "slots": [ "class_expression_any_of", @@ -8050,15 +8011,13 @@ "slot_conditions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ClassExpression", "@type": "ClassDefinition" }, { "name": "AnonymousClassExpression", "definition_uri": "https://w3id.org/linkml/AnonymousClassExpression", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:AnonymousClassExpression" - ], "is_a": "AnonymousExpression", "mixins": [ "ClassExpression" @@ -8107,6 +8066,7 @@ "slot_conditions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/AnonymousClassExpression", "@type": "ClassDefinition" }, { @@ -8129,9 +8089,6 @@ "message", "observation" ], - "exact_mappings": [ - "linkml:ClassDefinition" - ], "close_mappings": [ "owl:Class" ], @@ -8215,6 +8172,7 @@ "slot_conditions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ClassDefinition", "@type": "ClassDefinition" }, { @@ -8222,11 +8180,9 @@ "definition_uri": "https://w3id.org/linkml/ClassLevelRule", "description": "A rule that is applied to classes", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:ClassLevelRule" - ], "abstract": true, "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ClassLevelRule", "@type": "ClassDefinition" }, { @@ -8240,9 +8196,6 @@ "aliases": [ "if rule" ], - "exact_mappings": [ - "linkml:ClassRule" - ], "close_mappings": [ "sh:TripleRule", "swrl:Imp" @@ -8297,6 +8250,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ClassRule", "@type": "ClassDefinition" }, { @@ -8304,9 +8258,6 @@ "definition_uri": "https://w3id.org/linkml/ArrayExpression", "description": "defines the dimensions of an array", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:ArrayExpression" - ], "status": "testing", "mixins": [ "Extensible", @@ -8355,6 +8306,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ArrayExpression", "@type": "ClassDefinition" }, { @@ -8362,9 +8314,6 @@ "definition_uri": "https://w3id.org/linkml/DimensionExpression", "description": "defines one of the dimensions of an array", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:DimensionExpression" - ], "status": "testing", "mixins": [ "Extensible", @@ -8413,6 +8362,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/DimensionExpression", "@type": "ClassDefinition" }, { @@ -8420,9 +8370,6 @@ "definition_uri": "https://w3id.org/linkml/PatternExpression", "description": "a regular expression pattern used to evaluate conformance of a string", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:PatternExpression" - ], "mixins": [ "Extensible", "Annotatable", @@ -8469,6 +8416,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/PatternExpression", "@type": "ClassDefinition" }, { @@ -8476,9 +8424,6 @@ "definition_uri": "https://w3id.org/linkml/ImportExpression", "description": "an expression describing an import", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:ImportExpression" - ], "status": "testing", "mixins": [ "Extensible", @@ -8526,6 +8471,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ImportExpression", "@type": "ClassDefinition" }, { @@ -8536,14 +8482,12 @@ "SpecificationSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:Setting" - ], "slots": [ "setting_key", "setting_value" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Setting", "@type": "ClassDefinition" }, { @@ -8555,15 +8499,13 @@ "BasicSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:Prefix" - ], "rank": 12, "slots": [ "prefix_prefix", "prefix_reference" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Prefix", "@type": "ClassDefinition" }, { @@ -8571,14 +8513,12 @@ "definition_uri": "https://w3id.org/linkml/LocalName", "description": "an attributed label", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:LocalName" - ], "slots": [ "local_name_source", "local_name_value" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/LocalName", "@type": "ClassDefinition" }, { @@ -8589,15 +8529,13 @@ "BasicSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:Example" - ], "slots": [ "value", "value_description", "value_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Example", "@type": "ClassDefinition" }, { @@ -8611,14 +8549,12 @@ "aliases": [ "structured description" ], - "exact_mappings": [ - "linkml:AltDescription" - ], "slots": [ "alt_description_source", "alt_description_text" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/AltDescription", "@type": "ClassDefinition" }, { @@ -8633,9 +8569,6 @@ "aliases": [ "PV" ], - "exact_mappings": [ - "linkml:PermissibleValue" - ], "close_mappings": [ "skos:Concept" ], @@ -8690,6 +8623,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/PermissibleValue", "@type": "ClassDefinition" }, { @@ -8702,9 +8636,6 @@ "RelationalModelProfile" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:UniqueKey" - ], "rank": 20, "mixins": [ "Extensible", @@ -8752,6 +8683,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/UniqueKey", "@type": "ClassDefinition" }, { @@ -8762,9 +8694,6 @@ "SpecificationSubset" ], "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:TypeMapping" - ], "rank": 21, "mixins": [ "Extensible", @@ -8812,6 +8741,7 @@ "keywords" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/TypeMapping", "@type": "ClassDefinition" }, { @@ -8819,9 +8749,6 @@ "definition_uri": "https://w3id.org/linkml/ExtraSlotsExpression", "description": "An expression that defines how to handle additional data in an instance of class\nbeyond the slots/attributes defined for that class.\nSee `extra_slots` for usage examples.\n", "from_schema": "https://w3id.org/linkml/meta", - "exact_mappings": [ - "linkml:ExtraSlotsExpression" - ], "mixins": [ "Expression" ], @@ -8830,6 +8757,7 @@ "extra_slots_expression_range_expression" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/ExtraSlotsExpression", "@type": "ClassDefinition" }, { @@ -8841,6 +8769,7 @@ "linkml:Any" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any", "@type": "ClassDefinition" }, { @@ -8849,15 +8778,13 @@ "description": "a tag/value pair used to add non-model information to an entry", "from_schema": "https://w3id.org/linkml/extensions", "imported_from": "linkml:extensions", - "exact_mappings": [ - "linkml:Extension" - ], "slots": [ "extension_tag", "extension_value", "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extension", "@type": "ClassDefinition" }, { @@ -8866,14 +8793,12 @@ "description": "mixin for classes that support extension", "from_schema": "https://w3id.org/linkml/extensions", "imported_from": "linkml:extensions", - "exact_mappings": [ - "linkml:Extensible" - ], "mixin": true, "slots": [ "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extensible", "@type": "ClassDefinition" }, { @@ -8882,14 +8807,12 @@ "description": "mixin for classes that support annotations", "from_schema": "https://w3id.org/linkml/annotations", "imported_from": "linkml:annotations", - "exact_mappings": [ - "linkml:Annotatable" - ], "mixin": true, "slots": [ "annotations" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Annotatable", "@type": "ClassDefinition" }, { @@ -8898,9 +8821,6 @@ "description": "a tag/value pair with the semantics of OWL Annotation", "from_schema": "https://w3id.org/linkml/annotations", "imported_from": "linkml:annotations", - "exact_mappings": [ - "linkml:Annotation" - ], "is_a": "Extension", "mixins": [ "Annotatable" @@ -8912,6 +8832,7 @@ "annotations" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Annotation", "@type": "ClassDefinition" }, { @@ -8934,6 +8855,7 @@ "iec61360code" ], "slot_usage": {}, + "class_uri": "http://qudt.org/schema/qudt/Unit", "any_of": [ { "slot_conditions": [ diff --git a/tests/linkml/test_base/__snapshots__/meta.ttl b/tests/linkml/test_base/__snapshots__/meta.ttl index 5cef2ccfcc..aba88d9a0a 100644 --- a/tests/linkml/test_base/__snapshots__/meta.ttl +++ b/tests/linkml/test_base/__snapshots__/meta.ttl @@ -22,15 +22,15 @@ linkml:AltDescription OIO:inSubset linkml:BasicSubset ; a linkml:ClassDefinition ; skos:altLabel "structured description" ; - skos:exactMatch linkml:AltDescription ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/AltDescription"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AltDescription"^^xsd:anyURI ; linkml:description "an attributed description" ; linkml:slot_usage _:c14n16 ; linkml:slots linkml:alt_description_source , linkml:alt_description_text . linkml:Annotatable a linkml:ClassDefinition ; - skos:exactMatch linkml:Annotatable ; skos:inScheme "https://w3id.org/linkml/annotations"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Annotatable"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Annotatable"^^xsd:anyURI ; linkml:description "mixin for classes that support annotations" ; linkml:imported_from "linkml:annotations" ; @@ -38,8 +38,8 @@ linkml:Annotatable a linkml:ClassDefinition ; linkml:slot_usage _:c14n29 ; linkml:slots linkml:annotations . linkml:Annotation a linkml:ClassDefinition ; - skos:exactMatch linkml:Annotation ; skos:inScheme "https://w3id.org/linkml/annotations"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Annotation"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Annotation"^^xsd:anyURI ; linkml:description "a tag/value pair with the semantics of OWL Annotation" ; linkml:imported_from "linkml:annotations" ; @@ -48,42 +48,42 @@ linkml:Annotation a linkml:ClassDefinition ; linkml:slot_usage _:c14n92 ; linkml:slots linkml:annotations , linkml:extension_tag , linkml:extension_value , linkml:extensions . linkml:AnonymousClassExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:AnonymousClassExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/AnonymousClassExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnonymousClassExpression"^^xsd:anyURI ; linkml:is_a linkml:AnonymousExpression ; linkml:mixins linkml:ClassExpression ; linkml:slot_usage _:c14n55 ; linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:anonymous_class_expression_is_a , linkml:broad_mappings , linkml:categories , linkml:class_expression_all_of , linkml:class_expression_any_of , linkml:class_expression_exactly_one_of , linkml:class_expression_none_of , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:keywords , linkml:last_updated_on , linkml:mappings , linkml:modified_by , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:slot_conditions , linkml:source , linkml:status , linkml:structured_aliases , linkml:title , linkml:todos . linkml:AnonymousEnumExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:AnonymousEnumExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/AnonymousEnumExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnonymousEnumExpression"^^xsd:anyURI ; linkml:description "An enum_expression that is not named" ; linkml:mixins linkml:EnumExpression ; linkml:slot_usage _:c14n75 ; linkml:slots linkml:code_set , linkml:code_set_tag , linkml:code_set_version , linkml:concepts , linkml:include , linkml:inherits , linkml:matches , linkml:minus , linkml:permissible_values , linkml:pv_formula , linkml:reachable_from . linkml:AnonymousExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:AnonymousExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; skos:note "anonymous expressions are useful for when it is necessary to build a complex expression without introducing a named element for each sub-expression" ; linkml:abstract true ; + linkml:class_uri "https://w3id.org/linkml/AnonymousExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnonymousExpression"^^xsd:anyURI ; linkml:description "An abstract parent class for any nested expression" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Expression , linkml:Extensible ; linkml:slot_usage _:c14n81 ; linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:keywords , linkml:last_updated_on , linkml:mappings , linkml:modified_by , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:title , linkml:todos . linkml:AnonymousSlotExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:AnonymousSlotExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/AnonymousSlotExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnonymousSlotExpression"^^xsd:anyURI ; linkml:is_a linkml:AnonymousExpression ; linkml:mixins linkml:SlotExpression ; linkml:slot_usage _:c14n54 ; linkml:slots linkml:aliases , linkml:all_members , linkml:alt_descriptions , linkml:annotations , linkml:array , linkml:bindings , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:enum_range , linkml:equals_expression , linkml:equals_number , linkml:equals_string , linkml:equals_string_in , linkml:exact_cardinality , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:has_member , linkml:implicit_prefix , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:inlined , linkml:inlined_as_list , linkml:keywords , linkml:last_updated_on , linkml:mappings , linkml:maximum_cardinality , linkml:maximum_value , linkml:minimum_cardinality , linkml:minimum_value , linkml:modified_by , linkml:multivalued , linkml:narrow_mappings , linkml:notes , linkml:pattern , linkml:range , linkml:range_expression , linkml:rank , linkml:recommended , linkml:related_mappings , linkml:required , linkml:see_also , linkml:slot_expression_all_of , linkml:slot_expression_any_of , linkml:slot_expression_exactly_one_of , linkml:slot_expression_none_of , linkml:source , linkml:status , linkml:structured_aliases , linkml:structured_pattern , linkml:title , linkml:todos , linkml:unit , linkml:value_presence . linkml:AnonymousTypeExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:AnonymousTypeExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/AnonymousTypeExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnonymousTypeExpression"^^xsd:anyURI ; linkml:description "A type expression that is not a top-level named type definition. Used for nesting." ; linkml:mixins linkml:TypeExpression ; @@ -92,17 +92,19 @@ linkml:AnonymousTypeExpression a linkml:ClassDefinition ; linkml:AnyValue a linkml:ClassDefinition ; skos:exactMatch linkml:Any ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Any"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/AnyValue"^^xsd:anyURI ; linkml:imported_from "linkml:extensions" ; linkml:slot_usage _:c14n43 . linkml:Anything a linkml:ClassDefinition ; skos:exactMatch linkml:Any ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Any"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Anything"^^xsd:anyURI ; linkml:slot_usage _:c14n47 . linkml:ArrayExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:ArrayExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/ArrayExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ArrayExpression"^^xsd:anyURI ; linkml:description "defines the dimensions of an array" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -122,9 +124,9 @@ linkml:ClassDefinition OIO:inSubset linkml:BasicSubset , linkml:MinimalSubset , a linkml:ClassDefinition ; skos:altLabel "message" , "observation" , "record" , "table" , "template" ; skos:closeMatch owl:Class ; - skos:exactMatch linkml:ClassDefinition ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 2 ; + linkml:class_uri "https://w3id.org/linkml/ClassDefinition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ClassDefinition"^^xsd:anyURI ; linkml:description "an element whose instances are complex objects that may have slot-value assignments" ; linkml:is_a linkml:Definition ; @@ -132,17 +134,17 @@ linkml:ClassDefinition OIO:inSubset linkml:BasicSubset , linkml:MinimalSubset , linkml:slot_usage _:c14n35 ; linkml:slots linkml:abstract , linkml:alias , linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:attributes , linkml:broad_mappings , linkml:categories , linkml:children_are_mutually_disjoint , linkml:class_definition_apply_to , linkml:class_definition_disjoint_with , linkml:class_definition_is_a , linkml:class_definition_mixins , linkml:class_definition_rules , linkml:class_definition_union_of , linkml:class_expression_all_of , linkml:class_expression_any_of , linkml:class_expression_exactly_one_of , linkml:class_expression_none_of , linkml:class_uri , linkml:classification_rules , linkml:close_mappings , linkml:comments , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:defining_slots , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:extra_slots , linkml:from_schema , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:implements , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:instantiates , linkml:keywords , linkml:last_updated_on , linkml:local_names , linkml:mappings , linkml:mixin , linkml:modified_by , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:represents_relationship , linkml:see_also , linkml:slot_conditions , linkml:slot_names_unique , linkml:slot_usage , linkml:slots , linkml:source , linkml:status , linkml:string_serialization , linkml:structured_aliases , linkml:subclass_of , linkml:title , linkml:todos , linkml:tree_root , linkml:unique_keys , linkml:values_from . linkml:ClassExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:ClassExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/ClassExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ClassExpression"^^xsd:anyURI ; linkml:description "A boolean expression that can be used to dynamically determine membership of a class" ; linkml:mixin true ; linkml:slot_usage _:c14n74 ; linkml:slots linkml:class_expression_all_of , linkml:class_expression_any_of , linkml:class_expression_exactly_one_of , linkml:class_expression_none_of , linkml:slot_conditions . linkml:ClassLevelRule a linkml:ClassDefinition ; - skos:exactMatch linkml:ClassLevelRule ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; linkml:abstract true ; + linkml:class_uri "https://w3id.org/linkml/ClassLevelRule"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ClassLevelRule"^^xsd:anyURI ; linkml:description "A rule that is applied to classes" ; linkml:slot_usage _:c14n88 . @@ -150,8 +152,8 @@ linkml:ClassRule OIO:inSubset linkml:SpecificationSubset ; a linkml:ClassDefinition ; skos:altLabel "if rule" ; skos:closeMatch swrl:Imp , sh:TripleRule ; - skos:exactMatch linkml:ClassRule ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/ClassRule"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ClassRule"^^xsd:anyURI ; linkml:description "A rule that applies to instances of a class" ; linkml:is_a linkml:ClassLevelRule ; @@ -160,8 +162,8 @@ linkml:ClassRule OIO:inSubset linkml:SpecificationSubset ; linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:bidirectional , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deactivated , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:elseconditions , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:keywords , linkml:last_updated_on , linkml:mappings , linkml:modified_by , linkml:narrow_mappings , linkml:notes , linkml:open_world , linkml:postconditions , linkml:preconditions , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:title , linkml:todos . linkml:CommonMetadata OIO:inSubset linkml:BasicSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:CommonMetadata ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/CommonMetadata"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/CommonMetadata"^^xsd:anyURI ; linkml:description "Generic metadata shared across definitions" ; linkml:mixin true ; @@ -171,17 +173,17 @@ linkml:DISCOURAGED linkml:description "The metadata element is allowed but disco linkml:Definition OIO:inSubset linkml:BasicSubset ; a linkml:ClassDefinition ; rdfs:seeAlso "https://en.wikipedia.org/wiki/Data_element_definition"^^xsd:anyURI ; - skos:exactMatch linkml:Definition ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; linkml:abstract true ; + linkml:class_uri "https://w3id.org/linkml/Definition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Definition"^^xsd:anyURI ; linkml:description "abstract base class for core metaclasses" ; linkml:is_a linkml:Element ; linkml:slot_usage _:c14n8 ; linkml:slots linkml:abstract , linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:apply_to , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:implements , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:instantiates , linkml:is_a , linkml:keywords , linkml:last_updated_on , linkml:local_names , linkml:mappings , linkml:mixin , linkml:mixins , linkml:modified_by , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:string_serialization , linkml:structured_aliases , linkml:title , linkml:todos , linkml:values_from . linkml:DimensionExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:DimensionExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/DimensionExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/DimensionExpression"^^xsd:anyURI ; linkml:description "defines one of the dimensions of an array" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -194,9 +196,9 @@ linkml:Element OIO:inSubset linkml:BasicSubset ; a linkml:ClassDefinition ; rdfs:seeAlso "https://en.wikipedia.org/wiki/Data_element"^^xsd:anyURI ; skos:altLabel "data element" , "object" ; - skos:exactMatch linkml:Element ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; linkml:abstract true ; + linkml:class_uri "https://w3id.org/linkml/Element"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Element"^^xsd:anyURI ; linkml:description "A named element in the model" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -204,8 +206,8 @@ linkml:Element OIO:inSubset linkml:BasicSubset ; linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:implements , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:instantiates , linkml:keywords , linkml:last_updated_on , linkml:local_names , linkml:mappings , linkml:modified_by , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:title , linkml:todos . linkml:EnumBinding OIO:inSubset linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:EnumBinding ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/EnumBinding"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/EnumBinding"^^xsd:anyURI ; linkml:description "A binding of a slot or a class to a permissible value from an enumeration." ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -215,9 +217,10 @@ linkml:EnumDefinition OIO:inSubset linkml:BasicSubset , linkml:ObjectOrientedPro a linkml:ClassDefinition ; skos:altLabel "Terminology Value Set" , "answer list" , "code set" , "concept set" , "enum" , "enumeration" , "semantic enumeration" , "term set" , "value domain" , "value set" ; skos:closeMatch skos:ConceptScheme ; - skos:exactMatch NCIT:C113497 , qb:HierarchicalCodeList , cdisc:ValueDomain , linkml:EnumDefinition ; + skos:exactMatch NCIT:C113497 , qb:HierarchicalCodeList , cdisc:ValueDomain ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 5 ; + linkml:class_uri "https://w3id.org/linkml/EnumDefinition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/EnumDefinition"^^xsd:anyURI ; linkml:description "an element whose instances must be drawn from a specified set of permissible values" ; linkml:is_a linkml:Definition ; @@ -225,8 +228,8 @@ linkml:EnumDefinition OIO:inSubset linkml:BasicSubset , linkml:ObjectOrientedPro linkml:slot_usage _:c14n6 ; linkml:slots linkml:abstract , linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:apply_to , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:code_set , linkml:code_set_tag , linkml:code_set_version , linkml:comments , linkml:concepts , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:enum_uri , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:implements , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:include , linkml:inherits , linkml:instantiates , linkml:is_a , linkml:keywords , linkml:last_updated_on , linkml:local_names , linkml:mappings , linkml:matches , linkml:minus , linkml:mixin , linkml:mixins , linkml:modified_by , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:permissible_values , linkml:pv_formula , linkml:rank , linkml:reachable_from , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:string_serialization , linkml:structured_aliases , linkml:title , linkml:todos , linkml:values_from . linkml:EnumExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:EnumExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/EnumExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/EnumExpression"^^xsd:anyURI ; linkml:description "An expression that constrains the range of a slot" ; linkml:is_a linkml:Expression ; @@ -234,23 +237,23 @@ linkml:EnumExpression a linkml:ClassDefinition ; linkml:slots linkml:code_set , linkml:code_set_tag , linkml:code_set_version , linkml:concepts , linkml:include , linkml:inherits , linkml:matches , linkml:minus , linkml:permissible_values , linkml:pv_formula , linkml:reachable_from . linkml:Example OIO:inSubset linkml:BasicSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:Example ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Example"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Example"^^xsd:anyURI ; linkml:description "usage example and description" ; linkml:slot_usage _:c14n66 ; linkml:slots linkml:value , linkml:value_description , linkml:value_object . linkml:Expression a linkml:ClassDefinition ; - skos:exactMatch linkml:Expression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; linkml:abstract true ; + linkml:class_uri "https://w3id.org/linkml/Expression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Expression"^^xsd:anyURI ; linkml:description "general mixin for any class that can represent some form of expression" ; linkml:mixin true ; linkml:slot_usage _:c14n94 . linkml:Extensible a linkml:ClassDefinition ; - skos:exactMatch linkml:Extensible ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Extensible"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Extensible"^^xsd:anyURI ; linkml:description "mixin for classes that support extension" ; linkml:imported_from "linkml:extensions" ; @@ -258,16 +261,16 @@ linkml:Extensible a linkml:ClassDefinition ; linkml:slot_usage _:c14n87 ; linkml:slots linkml:extensions . linkml:Extension a linkml:ClassDefinition ; - skos:exactMatch linkml:Extension ; skos:inScheme "https://w3id.org/linkml/extensions"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Extension"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Extension"^^xsd:anyURI ; linkml:description "a tag/value pair used to add non-model information to an entry" ; linkml:imported_from "linkml:extensions" ; linkml:slot_usage _:c14n73 ; linkml:slots linkml:extension_tag , linkml:extension_value , linkml:extensions . linkml:ExtraSlotsExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:ExtraSlotsExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/ExtraSlotsExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ExtraSlotsExpression"^^xsd:anyURI ; linkml:description "An expression that defines how to handle additional data in an instance of class\nbeyond the slots/attributes defined for that class.\nSee `extra_slots` for usage examples.\n" ; linkml:mixins linkml:Expression ; @@ -275,8 +278,8 @@ linkml:ExtraSlotsExpression a linkml:ClassDefinition ; linkml:slots linkml:allowed , linkml:extra_slots_expression_range_expression . linkml:FHIR_CODING linkml:description "The permissible values are the set of FHIR coding elements derived from the code set" . linkml:ImportExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:ImportExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/ImportExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ImportExpression"^^xsd:anyURI ; linkml:description "an expression describing an import" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -285,16 +288,16 @@ linkml:ImportExpression a linkml:ClassDefinition ; linkml:status "testing" . linkml:LABEL linkml:description "The permissible values are the set of human readable labels in the code set" . linkml:LocalName a linkml:ClassDefinition ; - skos:exactMatch linkml:LocalName ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/LocalName"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/LocalName"^^xsd:anyURI ; linkml:description "an attributed label" ; linkml:slot_usage _:c14n91 ; linkml:slots linkml:local_name_source , linkml:local_name_value . linkml:MatchQuery OIO:inSubset linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:MatchQuery ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/MatchQuery"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/MatchQuery"^^xsd:anyURI ; linkml:description "A query that is used on an enum expression to dynamically obtain a set of permissible values via a query that matches on properties of the external concepts." ; linkml:slot_usage _:c14n48 ; @@ -327,16 +330,16 @@ linkml:PREDICATE skos:exactMatch owl:annotatedProperty ; linkml:description "a slot with this role connects a relationship to its predicate/property" ; linkml:meaning "rdf:predicate"^^xsd:anyURI . linkml:PathExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:PathExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/PathExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/PathExpression"^^xsd:anyURI ; linkml:description "An expression that describes an abstract path from an object to another through a sequence of slot lookups" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Expression , linkml:Extensible ; linkml:slot_usage _:c14n67 ; linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:keywords , linkml:last_updated_on , linkml:mappings , linkml:modified_by , linkml:narrow_mappings , linkml:notes , linkml:path_expression_all_of , linkml:path_expression_any_of , linkml:path_expression_exactly_one_of , linkml:path_expression_followed_by , linkml:path_expression_none_of , linkml:range_expression , linkml:rank , linkml:related_mappings , linkml:reversed , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:title , linkml:todos , linkml:traverse . linkml:PatternExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:PatternExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/PatternExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/PatternExpression"^^xsd:anyURI ; linkml:description "a regular expression pattern used to evaluate conformance of a string" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -346,9 +349,9 @@ linkml:PermissibleValue OIO:inSubset linkml:BasicSubset , linkml:SpecificationSu a linkml:ClassDefinition ; skos:altLabel "PV" ; skos:closeMatch skos:Concept ; - skos:exactMatch linkml:PermissibleValue ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 16 ; + linkml:class_uri "https://w3id.org/linkml/PermissibleValue"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/PermissibleValue"^^xsd:anyURI ; linkml:description "a permissible value, accompanied by intended text and an optional mapping to a concept URI" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -356,9 +359,9 @@ linkml:PermissibleValue OIO:inSubset linkml:BasicSubset , linkml:SpecificationSu linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:implements , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:instantiates , linkml:keywords , linkml:last_updated_on , linkml:mappings , linkml:meaning , linkml:modified_by , linkml:narrow_mappings , linkml:notes , linkml:permissible_value_is_a , linkml:permissible_value_mixins , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:text , linkml:title , linkml:todos , linkml:unit . linkml:Prefix OIO:inSubset linkml:BasicSubset , linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:Prefix ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 12 ; + linkml:class_uri "https://w3id.org/linkml/Prefix"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Prefix"^^xsd:anyURI ; linkml:description "prefix URI tuple" ; linkml:slot_usage _:c14n50 ; @@ -369,8 +372,8 @@ linkml:RELATED_SYNONYM linkml:meaning "skos:relatedMatch"^^xsd:anyURI . linkml:REQUIRED linkml:description "The metadata element is required to be present in the model" . linkml:ReachabilityQuery OIO:inSubset linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:ReachabilityQuery ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/ReachabilityQuery"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/ReachabilityQuery"^^xsd:anyURI ; linkml:description "A query that is used on an enum expression to dynamically obtain a set of permissible values via walking from a set of source nodes to a set of descendants or ancestors over a set of relationship types." ; linkml:slot_usage _:c14n25 ; @@ -389,9 +392,9 @@ linkml:SchemaDefinition OIO:inSubset linkml:BasicSubset , linkml:MinimalSubset , rdfs:seeAlso "https://en.wikipedia.org/wiki/Data_dictionary"^^xsd:anyURI ; skos:altLabel "data dictionary" , "data model" , "information model" , "logical model" , "model" , "schema" ; skos:closeMatch qb:ComponentSet , owl:Ontology ; - skos:exactMatch linkml:SchemaDefinition ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 1 ; + linkml:class_uri "https://w3id.org/linkml/SchemaDefinition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/SchemaDefinition"^^xsd:anyURI ; linkml:description "A collection of definitions that make up a schema or a data model." ; linkml:is_a linkml:Element ; @@ -400,8 +403,8 @@ linkml:SchemaDefinition OIO:inSubset linkml:BasicSubset , linkml:MinimalSubset , linkml:tree_root true . linkml:Setting OIO:inSubset linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:Setting ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/Setting"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/Setting"^^xsd:anyURI ; linkml:description "assignment of a key to a value" ; linkml:slot_usage _:c14n57 ; @@ -410,9 +413,9 @@ linkml:SlotDefinition OIO:inSubset linkml:BasicSubset , linkml:MinimalSubset , l a linkml:ClassDefinition ; skos:altLabel "attribute" , "column" , "field" , "property" , "slot" , "variable" ; skos:closeMatch qb:ComponentProperty , rdf:Property ; - skos:exactMatch linkml:SlotDefinition ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 3 ; + linkml:class_uri "https://w3id.org/linkml/SlotDefinition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/SlotDefinition"^^xsd:anyURI ; linkml:description "an element that describes how instances are related to other instances" ; linkml:is_a linkml:Definition ; @@ -420,8 +423,8 @@ linkml:SlotDefinition OIO:inSubset linkml:BasicSubset , linkml:MinimalSubset , l linkml:slot_usage _:c14n41 ; linkml:slots linkml:abstract , linkml:alias , linkml:aliases , linkml:all_members , linkml:alt_descriptions , linkml:annotations , linkml:array , linkml:asymmetric , linkml:bindings , linkml:broad_mappings , linkml:categories , linkml:children_are_mutually_disjoint , linkml:close_mappings , linkml:comments , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:designates_type , linkml:domain , linkml:domain_of , linkml:enum_range , linkml:equals_expression , linkml:equals_number , linkml:equals_string , linkml:equals_string_in , linkml:exact_cardinality , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:has_member , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:identifier , linkml:ifabsent , linkml:implements , linkml:implicit_prefix , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:inherited , linkml:inlined , linkml:inlined_as_list , linkml:instantiates , linkml:inverse , linkml:irreflexive , linkml:is_class_field , linkml:is_grouping_slot , linkml:is_usage_slot , linkml:key , linkml:keywords , linkml:last_updated_on , linkml:list_elements_ordered , linkml:list_elements_unique , linkml:local_names , linkml:locally_reflexive , linkml:mappings , linkml:maximum_cardinality , linkml:maximum_value , linkml:minimum_cardinality , linkml:minimum_value , linkml:mixin , linkml:modified_by , linkml:multivalued , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:owner , linkml:path_rule , linkml:pattern , linkml:range , linkml:range_expression , linkml:rank , linkml:readonly , linkml:recommended , linkml:reflexive , linkml:reflexive_transitive_form_of , linkml:related_mappings , linkml:relational_role , linkml:required , linkml:role , linkml:see_also , linkml:shared , linkml:singular_name , linkml:slot_definition_apply_to , linkml:slot_definition_disjoint_with , linkml:slot_definition_is_a , linkml:slot_definition_mixins , linkml:slot_definition_union_of , linkml:slot_expression_all_of , linkml:slot_expression_any_of , linkml:slot_expression_exactly_one_of , linkml:slot_expression_none_of , linkml:slot_group , linkml:slot_uri , linkml:source , linkml:status , linkml:string_serialization , linkml:structured_aliases , linkml:structured_pattern , linkml:subproperty_of , linkml:symmetric , linkml:title , linkml:todos , linkml:transitive , linkml:transitive_form_of , linkml:type_mappings , linkml:unit , linkml:usage_slot_name , linkml:value_presence , linkml:values_from . linkml:SlotExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:SlotExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/SlotExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/SlotExpression"^^xsd:anyURI ; linkml:description "an expression that constrains the range of values a slot can take" ; linkml:is_a linkml:Expression ; @@ -437,6 +440,7 @@ linkml:SpecificationSubset dcterms:title "specification subset" ; linkml:StructuredAlias a linkml:ClassDefinition ; skos:exactMatch skosxl:Label ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "http://www.w3.org/2008/05/skos-xl#Label"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/StructuredAlias"^^xsd:anyURI ; linkml:description "object that contains meta data about a synonym or alias including where it came from (source) and its scope (narrow, broad, etc.)" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Expression , linkml:Extensible ; @@ -444,9 +448,9 @@ linkml:StructuredAlias a linkml:ClassDefinition ; linkml:slots linkml:alias_contexts , linkml:alias_predicate , linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:broad_mappings , linkml:close_mappings , linkml:comments , linkml:contributors , linkml:created_by , linkml:created_on , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:keywords , linkml:last_updated_on , linkml:literal_form , linkml:mappings , linkml:modified_by , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:structured_alias_categories , linkml:structured_aliases , linkml:title , linkml:todos . linkml:SubsetDefinition OIO:inSubset linkml:BasicSubset , linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:SubsetDefinition ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 6 ; + linkml:class_uri "https://w3id.org/linkml/SubsetDefinition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/SubsetDefinition"^^xsd:anyURI ; linkml:description "an element that can be used to group other metamodel elements" ; linkml:is_a linkml:Element ; @@ -454,9 +458,9 @@ linkml:SubsetDefinition OIO:inSubset linkml:BasicSubset , linkml:SpecificationSu linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:implements , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:instantiates , linkml:keywords , linkml:last_updated_on , linkml:local_names , linkml:mappings , linkml:modified_by , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:rank , linkml:related_mappings , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:title , linkml:todos . linkml:TypeDefinition OIO:inSubset linkml:BasicSubset , linkml:OwlProfile , linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:TypeDefinition ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 4 ; + linkml:class_uri "https://w3id.org/linkml/TypeDefinition"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/TypeDefinition"^^xsd:anyURI ; linkml:description "an element that whose instances are atomic scalar values that can be mapped to primitive types" ; linkml:is_a linkml:Element ; @@ -464,8 +468,8 @@ linkml:TypeDefinition OIO:inSubset linkml:BasicSubset , linkml:OwlProfile , link linkml:slot_usage _:c14n1 ; linkml:slots linkml:aliases , linkml:alt_descriptions , linkml:annotations , linkml:base , linkml:broad_mappings , linkml:categories , linkml:close_mappings , linkml:comments , linkml:conforms_to , linkml:contributors , linkml:created_by , linkml:created_on , linkml:definition_uri , linkml:deprecated , linkml:deprecated_element_has_exact_replacement , linkml:deprecated_element_has_possible_replacement , linkml:description , linkml:equals_number , linkml:equals_string , linkml:equals_string_in , linkml:exact_mappings , linkml:examples , linkml:extensions , linkml:from_schema , linkml:id_prefixes , linkml:id_prefixes_are_closed , linkml:implements , linkml:implicit_prefix , linkml:imported_from , linkml:in_language , linkml:in_subset , linkml:instantiates , linkml:keywords , linkml:last_updated_on , linkml:local_names , linkml:mappings , linkml:maximum_value , linkml:minimum_value , linkml:modified_by , linkml:name , linkml:narrow_mappings , linkml:notes , linkml:pattern , linkml:rank , linkml:related_mappings , linkml:repr , linkml:see_also , linkml:source , linkml:status , linkml:structured_aliases , linkml:structured_pattern , linkml:title , linkml:todos , linkml:type_definition_union_of , linkml:type_expression_all_of , linkml:type_expression_any_of , linkml:type_expression_exactly_one_of , linkml:type_expression_none_of , linkml:type_uri , linkml:typeof , linkml:unit . linkml:TypeExpression a linkml:ClassDefinition ; - skos:exactMatch linkml:TypeExpression ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; + linkml:class_uri "https://w3id.org/linkml/TypeExpression"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/TypeExpression"^^xsd:anyURI ; linkml:description "An abstract class grouping named types and anonymous type expressions" ; linkml:is_a linkml:Expression ; @@ -474,9 +478,9 @@ linkml:TypeExpression a linkml:ClassDefinition ; linkml:slots linkml:equals_number , linkml:equals_string , linkml:equals_string_in , linkml:implicit_prefix , linkml:maximum_value , linkml:minimum_value , linkml:pattern , linkml:structured_pattern , linkml:type_expression_all_of , linkml:type_expression_any_of , linkml:type_expression_exactly_one_of , linkml:type_expression_none_of , linkml:unit . linkml:TypeMapping OIO:inSubset linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:TypeMapping ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 21 ; + linkml:class_uri "https://w3id.org/linkml/TypeMapping"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/TypeMapping"^^xsd:anyURI ; linkml:description "Represents how a slot or type can be serialized to a format." ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -485,9 +489,9 @@ linkml:TypeMapping OIO:inSubset linkml:SpecificationSubset ; linkml:URI linkml:description "The permissible values are the set of code URIs in the code set" . linkml:UniqueKey OIO:inSubset linkml:BasicSubset , linkml:RelationalModelProfile , linkml:SpecificationSubset ; a linkml:ClassDefinition ; - skos:exactMatch linkml:UniqueKey ; skos:inScheme "https://w3id.org/linkml/meta"^^xsd:anyURI ; sh:order 20 ; + linkml:class_uri "https://w3id.org/linkml/UniqueKey"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/UniqueKey"^^xsd:anyURI ; linkml:description "a collection of slots whose values uniquely identify an instance of a class" ; linkml:mixins linkml:Annotatable , linkml:CommonMetadata , linkml:Extensible ; @@ -497,6 +501,7 @@ linkml:UnitOfMeasure a linkml:ClassDefinition ; skos:exactMatch qudt:Unit ; skos:inScheme "https://w3id.org/linkml/units"^^xsd:anyURI ; linkml:any_of _:c14n13 , _:c14n58 , _:c14n72 , _:c14n9 ; + linkml:class_uri "http://qudt.org/schema/qudt/Unit"^^xsd:anyURI ; linkml:definition_uri "https://w3id.org/linkml/UnitOfMeasure"^^xsd:anyURI ; linkml:description "A unit of measure, or unit, is a particular quantity value that has been chosen as a scale for measuring other quantities the same kind (more generally of equivalent dimension)." ; linkml:imported_from "linkml:units" ; diff --git a/tests/linkml/test_biolink_model/__snapshots__/biolink.json b/tests/linkml/test_biolink_model/__snapshots__/biolink.json index 8854bcb862..3080ce2608 100644 --- a/tests/linkml/test_biolink_model/__snapshots__/biolink.json +++ b/tests/linkml/test_biolink_model/__snapshots__/biolink.json @@ -23274,14 +23274,12 @@ "definition_uri": "https://w3id.org/biolink/vocab/MappingCollection", "description": "A collection of deprecated mappings.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MappingCollection" - ], "abstract": true, "slots": [ "predicate_mappings" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MappingCollection", "tree_root": true, "@type": "ClassDefinition" }, @@ -23290,9 +23288,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PredicateMapping", "description": "A deprecated predicate mapping object contains the deprecated predicate and an example of the rewiring that should be done to use a qualified statement in its place.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PredicateMapping" - ], "slots": [ "mapped_predicate", "subject_aspect_qualifier", @@ -23317,6 +23312,7 @@ "broad_match" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PredicateMapping", "@type": "ClassDefinition" }, { @@ -23344,7 +23340,6 @@ "https://github.com/biolink/biolink-model/issues/486" ], "exact_mappings": [ - "biolink:OntologyClass", "owl:Class", "schema:Class" ], @@ -23353,6 +23348,7 @@ "id" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OntologyClass", "@type": "ClassDefinition" }, { @@ -23360,11 +23356,9 @@ "definition_uri": "https://w3id.org/biolink/vocab/Annotation", "description": "Biolink Model root class for entity annotations.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:Annotation" - ], "abstract": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Annotation", "@type": "ClassDefinition" }, { @@ -23372,15 +23366,13 @@ "definition_uri": "https://w3id.org/biolink/vocab/QuantityValue", "description": "A value of an attribute that is quantitative and measurable, expressed as a combination of a unit and a numeric value", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:QuantityValue" - ], "is_a": "Annotation", "slots": [ "has_unit", "has_numeric_value" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/QuantityValue", "@type": "ClassDefinition" }, { @@ -23398,7 +23390,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Attribute", "SIO:000614" ], "is_a": "NamedThing", @@ -23423,6 +23414,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Attribute", "@type": "ClassDefinition" }, { @@ -23440,7 +23432,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ChemicalRole", "CHEBI:51086" ], "is_a": "Attribute", @@ -23462,6 +23453,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalRole", "@type": "ClassDefinition" }, { @@ -23469,7 +23461,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/BiologicalSex", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:BiologicalSex", "PATO:0000047" ], "is_a": "Attribute", @@ -23491,6 +23482,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BiologicalSex", "@type": "ClassDefinition" }, { @@ -23499,7 +23491,6 @@ "description": "An attribute corresponding to the phenotypic sex of the individual, based upon the reproductive organs present.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PhenotypicSex", "PATO:0001894" ], "is_a": "BiologicalSex", @@ -23521,6 +23512,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhenotypicSex", "@type": "ClassDefinition" }, { @@ -23529,7 +23521,6 @@ "description": "An attribute corresponding to the genotypic sex of the individual, based upon genotypic composition of sex chromosomes.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:GenotypicSex", "PATO:0020000" ], "is_a": "BiologicalSex", @@ -23551,6 +23542,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypicSex", "@type": "ClassDefinition" }, { @@ -23559,9 +23551,6 @@ "description": "describes the severity of a phenotypic feature or disease", "deprecated": "True", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SeverityValue" - ], "is_a": "Attribute", "slots": [ "id", @@ -23581,41 +23570,36 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SeverityValue", "@type": "ClassDefinition" }, { "name": "RelationshipQuantifier", "definition_uri": "https://w3id.org/biolink/vocab/RelationshipQuantifier", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:RelationshipQuantifier" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RelationshipQuantifier", "@type": "ClassDefinition" }, { "name": "SensitivityQuantifier", "definition_uri": "https://w3id.org/biolink/vocab/SensitivityQuantifier", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SensitivityQuantifier" - ], "is_a": "RelationshipQuantifier", "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SensitivityQuantifier", "@type": "ClassDefinition" }, { "name": "SpecificityQuantifier", "definition_uri": "https://w3id.org/biolink/vocab/SpecificityQuantifier", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SpecificityQuantifier" - ], "is_a": "RelationshipQuantifier", "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SpecificityQuantifier", "@type": "ClassDefinition" }, { @@ -23623,21 +23607,16 @@ "definition_uri": "https://w3id.org/biolink/vocab/PathognomonicityQuantifier", "description": "A relationship quantifier between a variant or symptom and a disease, which is high when the presence of the feature implies the existence of the disease", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PathognomonicityQuantifier" - ], "is_a": "SpecificityQuantifier", "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathognomonicityQuantifier", "@type": "ClassDefinition" }, { "name": "FrequencyQuantifier", "definition_uri": "https://w3id.org/biolink/vocab/FrequencyQuantifier", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:FrequencyQuantifier" - ], "is_a": "RelationshipQuantifier", "mixin": true, "slots": [ @@ -23647,6 +23626,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/FrequencyQuantifier", "@type": "ClassDefinition" }, { @@ -23659,11 +23639,9 @@ ], "definition_uri": "https://w3id.org/biolink/vocab/ChemicalOrDrugOrTreatment", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalOrDrugOrTreatment" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalOrDrugOrTreatment", "@type": "ClassDefinition" }, { @@ -23671,9 +23649,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Entity", "description": "Root Biolink Model class for all things and informational relationships, real or imagined.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:Entity" - ], "abstract": true, "slots": [ "id", @@ -23686,6 +23661,7 @@ "deprecated" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Entity", "@type": "ClassDefinition" }, { @@ -23694,7 +23670,6 @@ "description": "a databased entity or concept/class", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:NamedThing", "BFO:0000001", "WIKIDATA:Q35120", "UMLSSG:OBJC", @@ -23717,6 +23692,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/NamedThing", "@type": "ClassDefinition" }, { @@ -23724,14 +23700,12 @@ "definition_uri": "https://w3id.org/biolink/vocab/RelationshipType", "description": "An OWL property used as an edge label", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:RelationshipType" - ], "is_a": "OntologyClass", "slots": [ "id" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RelationshipType", "@type": "ClassDefinition" }, { @@ -23745,14 +23719,12 @@ "mappings": [ "WIKIDATA:Q427626" ], - "exact_mappings": [ - "biolink:TaxonomicRank" - ], "is_a": "OntologyClass", "slots": [ "id" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/TaxonomicRank", "@type": "ClassDefinition" }, { @@ -23773,7 +23745,6 @@ "taxonomic classification" ], "exact_mappings": [ - "biolink:OrganismTaxon", "WIKIDATA:Q16521", "STY:T001", "bioschemas:Taxon" @@ -23801,6 +23772,7 @@ "organism_taxon_has_taxonomic_rank" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismTaxon", "@type": "ClassDefinition" }, { @@ -23809,7 +23781,6 @@ "description": "Something that happens at a given place and time.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Event", "NCIT:C25499", "STY:T051" ], @@ -23829,15 +23800,13 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Event", "@type": "ClassDefinition" }, { "name": "AdministrativeEntity", "definition_uri": "https://w3id.org/biolink/vocab/AdministrativeEntity", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:AdministrativeEntity" - ], "is_a": "NamedThing", "abstract": true, "slots": [ @@ -23855,6 +23824,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/AdministrativeEntity", "@type": "ClassDefinition" }, { @@ -23865,9 +23835,6 @@ "The data/metadata included in a Study Result object are typically a subset of data from a larger study data set, that are selected by a curator because they may be useful as evidence for deriving knowledge about a specific focus of the study. The notion of a 'study' here is defined broadly to include any research activity at any scale that is aimed at generating knowledge or hypotheses. This may include a single assay or computational analyses, or a larger scale clinical trial or experimental research investigation." ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:StudyResult" - ], "is_a": "InformationContentEntity", "abstract": true, "slots": [ @@ -23889,6 +23856,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/StudyResult", "@type": "ClassDefinition" }, { @@ -23897,7 +23865,6 @@ "description": "a detailed investigation and/or analysis", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Study", "NCIT:C63536" ], "close_mappings": [ @@ -23923,6 +23890,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Study", "@type": "ClassDefinition" }, { @@ -23930,9 +23898,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/StudyVariable", "description": "a variable that is used as a measure in the investigation of a study", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:StudyVariable" - ], "close_mappings": [ "STATO:0000258", "SIO:000367" @@ -23960,6 +23925,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/StudyVariable", "@type": "ClassDefinition" }, { @@ -23967,9 +23933,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CommonDataElement", "description": "A Common Data Element (CDE) is a standardized, precisely defined question, paired with a set of allowable responses, used systematically across different sites, studies, or clinical trials to ensure consistent data collection. Multiple CDEs (from one or more Collections) can be curated into Forms. (https://cde.nlm.nih.gov/home)", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CommonDataElement" - ], "close_mappings": [ "NCIT:C19984" ], @@ -23993,6 +23956,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CommonDataElement", "@type": "ClassDefinition" }, { @@ -24000,9 +23964,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ConceptCountAnalysisResult", "description": "A result of a concept count analysis.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ConceptCountAnalysisResult" - ], "is_a": "StudyResult", "slots": [ "id", @@ -24023,6 +23984,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ConceptCountAnalysisResult", "@type": "ClassDefinition" }, { @@ -24030,9 +23992,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ObservedExpectedFrequencyAnalysisResult", "description": "A result of a observed expected frequency analysis.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ObservedExpectedFrequencyAnalysisResult" - ], "is_a": "StudyResult", "slots": [ "id", @@ -24053,6 +24012,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ObservedExpectedFrequencyAnalysisResult", "@type": "ClassDefinition" }, { @@ -24060,9 +24020,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/RelativeFrequencyAnalysisResult", "description": "A result of a relative frequency analysis.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:RelativeFrequencyAnalysisResult" - ], "is_a": "StudyResult", "slots": [ "id", @@ -24083,6 +24040,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RelativeFrequencyAnalysisResult", "@type": "ClassDefinition" }, { @@ -24090,9 +24048,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/TextMiningResult", "description": "A result of text mining.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:TextMiningResult" - ], "is_a": "StudyResult", "slots": [ "id", @@ -24113,6 +24068,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/TextMiningResult", "@type": "ClassDefinition" }, { @@ -24120,9 +24076,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChiSquaredAnalysisResult", "description": "A result of a chi squared analysis.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChiSquaredAnalysisResult" - ], "is_a": "StudyResult", "slots": [ "id", @@ -24143,6 +24096,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChiSquaredAnalysisResult", "@type": "ClassDefinition" }, { @@ -24150,9 +24104,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/LogOddsAnalysisResult", "description": "A result of a log odds ratio analysis.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:LogOddsAnalysisResult" - ], "is_a": "StudyResult", "slots": [ "id", @@ -24173,6 +24124,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/LogOddsAnalysisResult", "@type": "ClassDefinition" }, { @@ -24192,7 +24144,6 @@ "group" ], "exact_mappings": [ - "biolink:Agent", "prov:Agent", "dct:Agent" ], @@ -24222,6 +24173,7 @@ "agent_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Agent", "@type": "ClassDefinition" }, { @@ -24238,7 +24190,6 @@ "information entity" ], "exact_mappings": [ - "biolink:InformationContentEntity", "IAO:0000030" ], "narrow_mappings": [ @@ -24276,6 +24227,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/InformationContentEntity", "@type": "ClassDefinition" }, { @@ -24284,7 +24236,6 @@ "description": "an item that refers to a collection of data from a data source.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Dataset", "IAO:0000100", "dctypes:Dataset", "schema:dataset", @@ -24310,6 +24261,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Dataset", "@type": "ClassDefinition" }, { @@ -24318,7 +24270,6 @@ "description": "an item that holds distribution level information about a dataset.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:DatasetDistribution", "dcat:Distribution" ], "is_a": "InformationContentEntity", @@ -24342,6 +24293,7 @@ "distribution_download_url" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DatasetDistribution", "@type": "ClassDefinition" }, { @@ -24349,9 +24301,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DatasetVersion", "description": "an item that holds version level information about a dataset.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DatasetVersion" - ], "is_a": "InformationContentEntity", "slots": [ "id", @@ -24375,6 +24324,7 @@ "has_distribution" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DatasetVersion", "@type": "ClassDefinition" }, { @@ -24382,9 +24332,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DatasetSummary", "description": "an item that holds summary level information about a dataset.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DatasetSummary" - ], "is_a": "InformationContentEntity", "slots": [ "id", @@ -24407,6 +24354,7 @@ "source_logo" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DatasetSummary", "@type": "ClassDefinition" }, { @@ -24415,7 +24363,6 @@ "description": "Level of confidence in a statement", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ConfidenceLevel", "CIO:0000028", "SEPIO:0000187" ], @@ -24445,6 +24392,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ConfidenceLevel", "@type": "ClassDefinition" }, { @@ -24456,7 +24404,6 @@ "evidence code" ], "exact_mappings": [ - "biolink:EvidenceType", "ECO:0000000" ], "is_a": "InformationContentEntity", @@ -24482,6 +24429,7 @@ "creation_date" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EvidenceType", "@type": "ClassDefinition" }, { @@ -24499,7 +24447,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Publication", "IAO:0000311" ], "narrow_mappings": [ @@ -24532,6 +24479,7 @@ "publication_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Publication", "@type": "ClassDefinition" }, { @@ -24546,9 +24494,6 @@ "model_organism_database" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:Book" - ], "is_a": "Publication", "slots": [ "iri", @@ -24575,6 +24520,7 @@ "book_type" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Book", "@type": "ClassDefinition" }, { @@ -24584,9 +24530,6 @@ "model_organism_database" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:BookChapter" - ], "is_a": "Publication", "slots": [ "iri", @@ -24616,6 +24559,7 @@ "chapter" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BookChapter", "@type": "ClassDefinition" }, { @@ -24633,9 +24577,6 @@ "aliases": [ "journal" ], - "exact_mappings": [ - "biolink:Serial" - ], "is_a": "Publication", "slots": [ "iri", @@ -24665,6 +24606,7 @@ "serial_type" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Serial", "@type": "ClassDefinition" }, { @@ -24679,7 +24621,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Article", "SIO:000154", "fabio:article" ], @@ -24713,6 +24654,7 @@ "issue" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Article", "@type": "ClassDefinition" }, { @@ -24726,7 +24668,6 @@ "description": "an article, typically presenting results of research, that is published in an issue of a scientific journal.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:JournalArticle", "IAO:0000013", "fabio:JournalArticle" ], @@ -24760,6 +24701,7 @@ "issue" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/JournalArticle", "@type": "ClassDefinition" }, { @@ -24768,7 +24710,6 @@ "description": "a legal document granted by a patent issuing authority which confers upon the patenter the sole right to make, use and sell an invention for a set period of time.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Patent", "SIO:000153", "IAO:0000313", "fabio:Patent" @@ -24799,6 +24740,7 @@ "publication_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Patent", "@type": "ClassDefinition" }, { @@ -24807,7 +24749,6 @@ "description": "a document that is published according to World Wide Web standards, which may incorporate text, graphics, sound, and/or other features.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:WebPage", "SIO:000302", "NCIT-OBO:C142749", "fabio:WebPage" @@ -24838,6 +24779,7 @@ "publication_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/WebPage", "@type": "ClassDefinition" }, { @@ -24846,7 +24788,6 @@ "description": "a document reresenting an early version of an author's original scholarly work, such as a research paper or a review, prior to formal peer review and publication in a peer-reviewed scholarly or scientific journal.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PreprintPublication", "EFO:0010558", "fabio:Preprint" ], @@ -24876,6 +24817,7 @@ "publication_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PreprintPublication", "@type": "ClassDefinition" }, { @@ -24883,9 +24825,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DrugLabel", "description": "a document accompanying a drug or its container that provides written, printed or graphic information about the drug, including drug contents, specific instructions or warnings for administration, storage and disposal instructions, etc.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DrugLabel" - ], "broad_mappings": [ "NCIT-OBO:C41203" ], @@ -24915,6 +24854,7 @@ "publication_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DrugLabel", "@type": "ClassDefinition" }, { @@ -24922,9 +24862,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/RetrievalSource", "description": "Provides information about how a particular InformationResource served as a source from which knowledge expressed in an Edge, or data used to generate this knowledge, was retrieved.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:RetrievalSource" - ], "is_a": "InformationContentEntity", "slots": [ "id", @@ -24948,6 +24885,7 @@ "xref" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RetrievalSource", "@type": "ClassDefinition" }, { @@ -24955,11 +24893,9 @@ "definition_uri": "https://w3id.org/biolink/vocab/PhysicalEssenceOrOccurrent", "description": "Either a physical or processual entity.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PhysicalEssenceOrOccurrent" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhysicalEssenceOrOccurrent", "@type": "ClassDefinition" }, { @@ -24967,12 +24903,10 @@ "definition_uri": "https://w3id.org/biolink/vocab/PhysicalEssence", "description": "Semantic mixin concept. Pertains to entities that have physical properties such as mass, volume, or charge.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PhysicalEssence" - ], "is_a": "PhysicalEssenceOrOccurrent", "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhysicalEssence", "@type": "ClassDefinition" }, { @@ -24981,7 +24915,6 @@ "description": "An entity that has material reality (a.k.a. physical essence).", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PhysicalEntity", "STY:T072" ], "narrow_mappings": [ @@ -25006,6 +24939,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhysicalEntity", "@type": "ClassDefinition" }, { @@ -25014,12 +24948,12 @@ "description": "A processual entity.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Occurrent", "BFO:0000003" ], "is_a": "PhysicalEssenceOrOccurrent", "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Occurrent", "@type": "ClassDefinition" }, { @@ -25028,12 +24962,12 @@ "description": "Activity or behavior of any independent integral living, organization or mechanical actor in the world", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ActivityAndBehavior", "UMLSSG:ACTI" ], "is_a": "Occurrent", "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ActivityAndBehavior", "@type": "ClassDefinition" }, { @@ -25042,7 +24976,6 @@ "description": "An activity is something that occurs over a period of time and acts upon or with entities; it may include consuming, processing, transforming, modifying, relocating, using, or generating entities.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Activity", "prov:Activity", "NCIT:C43431", "STY:T052" @@ -25075,6 +25008,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Activity", "@type": "ClassDefinition" }, { @@ -25086,7 +25020,6 @@ "description": "A series of actions conducted in a certain order or manner", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Procedure", "UMLSSG:PROC", "dcid:MedicalProcedure" ], @@ -25115,6 +25048,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Procedure", "@type": "ClassDefinition" }, { @@ -25123,7 +25057,6 @@ "description": "a fact or situation that is observed to exist or happen, especially one whose cause or explanation is in question", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Phenomenon", "UMLSSG:PHEN" ], "narrow_mappings": [ @@ -25155,6 +25088,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Phenomenon", "@type": "ClassDefinition" }, { @@ -25162,9 +25096,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Device", "description": "A thing made or adapted for a particular purpose, especially a piece of mechanical or electronic equipment", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:Device" - ], "narrow_mappings": [ "UMLSSG:DEVI", "STY:T074", @@ -25187,6 +25118,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Device", "@type": "ClassDefinition" }, { @@ -25195,7 +25127,6 @@ "description": "A device or substance used to help diagnose disease or injury", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:DiagnosticAid", "STY:T130", "SNOMED:2949005" ], @@ -25215,6 +25146,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiagnosticAid", "@type": "ClassDefinition" }, { @@ -25222,9 +25154,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/StudyPopulation", "description": "A group of people banded together or treated as a group as participants in a research study.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:StudyPopulation" - ], "close_mappings": [ "WIKIDATA:Q7229825" ], @@ -25246,6 +25175,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/StudyPopulation", "@type": "ClassDefinition" }, { @@ -25253,11 +25183,9 @@ "definition_uri": "https://w3id.org/biolink/vocab/SubjectOfInvestigation", "description": "An entity that has the role of being studied in an investigation, study, or experiment", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SubjectOfInvestigation" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SubjectOfInvestigation", "@type": "ClassDefinition" }, { @@ -25276,7 +25204,6 @@ "physical sample" ], "exact_mappings": [ - "biolink:MaterialSample", "OBI:0000747", "SIO:001050" ], @@ -25299,6 +25226,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MaterialSample", "@type": "ClassDefinition" }, { @@ -25306,9 +25234,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PlanetaryEntity", "description": "Any entity or process that exists at the level of the whole planet", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PlanetaryEntity" - ], "is_a": "NamedThing", "slots": [ "id", @@ -25325,6 +25250,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PlanetaryEntity", "@type": "ClassDefinition" }, { @@ -25332,7 +25258,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EnvironmentalProcess", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:EnvironmentalProcess", "ENVO:02500000" ], "is_a": "PlanetaryEntity", @@ -25354,6 +25279,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EnvironmentalProcess", "@type": "ClassDefinition" }, { @@ -25361,7 +25287,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EnvironmentalFeature", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:EnvironmentalFeature", "ENVO:01000254" ], "is_a": "PlanetaryEntity", @@ -25380,6 +25305,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EnvironmentalFeature", "@type": "ClassDefinition" }, { @@ -25388,7 +25314,6 @@ "description": "a location that can be described in lat/long coordinates", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:GeographicLocation", "UMLSSG:GEOG", "STY:T083" ], @@ -25410,6 +25335,7 @@ "longitude" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeographicLocation", "@type": "ClassDefinition" }, { @@ -25417,9 +25343,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeographicLocationAtTime", "description": "a location that can be described in lat/long coordinates, for a particular time", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeographicLocationAtTime" - ], "is_a": "GeographicLocation", "slots": [ "id", @@ -25439,6 +25362,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeographicLocationAtTime", "@type": "ClassDefinition" }, { @@ -25446,15 +25370,13 @@ "definition_uri": "https://w3id.org/biolink/vocab/ThingWithTaxon", "description": "A mixin that can be used on any entity that can be taxonomically classified. This includes individual organisms; genes, their products and other molecular entities; body parts; biological processes", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ThingWithTaxon" - ], "mixin": true, "slots": [ "in_taxon", "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ThingWithTaxon", "@type": "ClassDefinition" }, { @@ -25464,9 +25386,6 @@ "aliases": [ "bioentity" ], - "exact_mappings": [ - "biolink:BiologicalEntity" - ], "narrow_mappings": [ "WIKIDATA:Q28845870", "STY:T050", @@ -25495,6 +25414,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BiologicalEntity", "@type": "ClassDefinition" }, { @@ -25504,9 +25424,6 @@ "translator_minimal" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenomicEntity" - ], "narrow_mappings": [ "STY:T028", "GENO:0000897" @@ -25516,6 +25433,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenomicEntity", "@type": "ClassDefinition" }, { @@ -25525,14 +25443,12 @@ "translator_minimal" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EpigenomicEntity" - ], "mixin": true, "slots": [ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EpigenomicEntity", "@type": "ClassDefinition" }, { @@ -25569,9 +25485,6 @@ "translator_minimal" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MolecularEntity" - ], "narrow_mappings": [ "STY:T088", "STY:T085", @@ -25600,6 +25513,7 @@ "is_metabolite" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MolecularEntity", "@type": "ClassDefinition" }, { @@ -25637,7 +25551,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ChemicalEntity", "CHEBI:24431", "SIO:010004", "WIKIDATA:Q79529", @@ -25678,6 +25591,7 @@ "has_chemical_role" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalEntity", "@type": "ClassDefinition" }, { @@ -25718,9 +25632,6 @@ "aliases": [ "chemical substance" ], - "exact_mappings": [ - "biolink:SmallMolecule" - ], "narrow_mappings": [ "STY:T196", "CHEBI:59999", @@ -25758,6 +25669,7 @@ "is_metabolite" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SmallMolecule", "@type": "ClassDefinition" }, { @@ -25794,9 +25706,6 @@ "translator_minimal" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalMixture" - ], "close_mappings": [ "dcid:ChemicalCompound" ], @@ -25829,6 +25738,7 @@ "routes_of_delivery" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalMixture", "@type": "ClassDefinition" }, { @@ -25868,7 +25778,6 @@ "genomic entity" ], "exact_mappings": [ - "biolink:NucleicAcidEntity", "SO:0000110" ], "narrow_mappings": [ @@ -25906,6 +25815,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/NucleicAcidEntity", "@type": "ClassDefinition" }, { @@ -25917,7 +25827,6 @@ "regulatory element" ], "exact_mappings": [ - "biolink:RegulatoryRegion", "SO:0005836", "SIO:001225", "WIKIDATA:Q3238407" @@ -25947,6 +25856,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RegulatoryRegion", "@type": "ClassDefinition" }, { @@ -25959,7 +25869,6 @@ "atac-seq accessible region" ], "exact_mappings": [ - "biolink:AccessibleDnaRegion", "SO:0002231" ], "is_a": "RegulatoryRegion", @@ -25987,6 +25896,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/AccessibleDnaRegion", "@type": "ClassDefinition" }, { @@ -25999,7 +25909,6 @@ "binding site" ], "exact_mappings": [ - "biolink:TranscriptionFactorBindingSite", "SO:0000235" ], "is_a": "RegulatoryRegion", @@ -26027,6 +25936,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/TranscriptionFactorBindingSite", "@type": "ClassDefinition" }, { @@ -26063,9 +25973,6 @@ "translator_minimal" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MolecularMixture" - ], "is_a": "ChemicalMixture", "slots": [ "id", @@ -26091,6 +25998,7 @@ "routes_of_delivery" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MolecularMixture", "@type": "ClassDefinition" }, { @@ -26124,9 +26032,6 @@ "translator_minimal" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ComplexMolecularMixture" - ], "is_a": "ChemicalMixture", "slots": [ "id", @@ -26152,6 +26057,7 @@ "routes_of_delivery" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ComplexMolecularMixture", "@type": "ClassDefinition" }, { @@ -26163,9 +26069,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/BiologicalProcessOrActivity", "description": "Either an individual molecular activity, or a collection of causally connected molecular activities in a biological system.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:BiologicalProcessOrActivity" - ], "is_a": "BiologicalEntity", "mixins": [ "Occurrent", @@ -26191,6 +26094,7 @@ "enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BiologicalProcessOrActivity", "@type": "ClassDefinition" }, { @@ -26219,7 +26123,6 @@ "reaction" ], "exact_mappings": [ - "biolink:MolecularActivity", "GO:0003674", "STY:T044" ], @@ -26251,6 +26154,7 @@ "molecular_activity_enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MolecularActivity", "@type": "ClassDefinition" }, { @@ -26266,7 +26170,6 @@ "description": "One or more causally connected executions of molecular functions", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:BiologicalProcess", "GO:0008150", "SIO:000006", "WIKIDATA:Q2996394" @@ -26299,6 +26202,7 @@ "enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BiologicalProcess", "@type": "ClassDefinition" }, { @@ -26319,7 +26223,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Pathway", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Pathway", "PW:0000001", "WIKIDATA:Q4915012" ], @@ -26351,6 +26254,7 @@ "enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Pathway", "@type": "ClassDefinition" }, { @@ -26365,7 +26269,6 @@ "physiology" ], "exact_mappings": [ - "biolink:PhysiologicalProcess", "STY:T039", "WIKIDATA:Q30892994" ], @@ -26399,6 +26302,7 @@ "enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhysiologicalProcess", "@type": "ClassDefinition" }, { @@ -26406,7 +26310,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Behavior", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Behavior", "GO:0007610", "STY:T053" ], @@ -26440,6 +26343,7 @@ "enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Behavior", "@type": "ClassDefinition" }, { @@ -26475,7 +26379,6 @@ "description": "A chemical entity (often a mixture) processed for consumption for nutritional, medical or technical use. Is a material entity that is created or changed during material processing.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ProcessedMaterial", "OBI:0000047" ], "is_a": "ChemicalMixture", @@ -26503,6 +26406,7 @@ "routes_of_delivery" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ProcessedMaterial", "@type": "ClassDefinition" }, { @@ -26543,7 +26447,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Drug", "WIKIDATA:Q12140", "CHEBI:23888", "STY:T200", @@ -26584,6 +26487,7 @@ "routes_of_delivery" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Drug", "@type": "ClassDefinition" }, { @@ -26617,9 +26521,6 @@ ], "definition_uri": "https://w3id.org/biolink/vocab/EnvironmentalFoodContaminant", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EnvironmentalFoodContaminant" - ], "related_mappings": [ "CHEBI:78299" ], @@ -26644,6 +26545,7 @@ "has_chemical_role" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EnvironmentalFoodContaminant", "@type": "ClassDefinition" }, { @@ -26677,9 +26579,6 @@ ], "definition_uri": "https://w3id.org/biolink/vocab/FoodAdditive", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:FoodAdditive" - ], "related_mappings": [ "CHEBI:64047" ], @@ -26704,6 +26603,7 @@ "has_chemical_role" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/FoodAdditive", "@type": "ClassDefinition" }, { @@ -26739,9 +26639,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Food", "description": "A substance consumed by a living organism as a source of nutrition", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:Food" - ], "is_a": "ChemicalMixture", "slots": [ "id", @@ -26767,6 +26664,7 @@ "routes_of_delivery" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Food", "@type": "ClassDefinition" }, { @@ -26775,7 +26673,6 @@ "description": "describes a characteristic of an organismal entity.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:OrganismAttribute", "STY:T032" ], "is_a": "Attribute", @@ -26797,6 +26694,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismAttribute", "@type": "ClassDefinition" }, { @@ -26816,9 +26714,6 @@ "mappings": [ "PATO:0000001" ], - "exact_mappings": [ - "biolink:PhenotypicQuality" - ], "is_a": "OrganismAttribute", "slots": [ "id", @@ -26838,6 +26733,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhenotypicQuality", "@type": "ClassDefinition" }, { @@ -26854,7 +26750,6 @@ "inheritance" ], "exact_mappings": [ - "biolink:GeneticInheritance", "HP:0000005", "GENO:0000141", "NCIT:C45827" @@ -26880,6 +26775,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneticInheritance", "@type": "ClassDefinition" }, { @@ -26888,7 +26784,6 @@ "description": "A named entity that is either a part of an organism, a whole organism, population or clade of organisms, excluding chemical entities", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:OrganismalEntity", "WIKIDATA:Q7239", "UMLSSG:LIVB", "CARO:0001010" @@ -26918,6 +26813,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismalEntity", "@type": "ClassDefinition" }, { @@ -26926,7 +26822,6 @@ "description": "A member of a group of unicellular microorganisms lacking a nuclear membrane, that reproduce by binary fission and are often motile.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Bacterium", "NCBITaxon:1869227", "STY:T007" ], @@ -26948,6 +26843,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Bacterium", "@type": "ClassDefinition" }, { @@ -26956,7 +26852,6 @@ "description": "A virus is a microorganism that replicates itself as a microRNA and infects the host cell.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Virus", "NCBITaxon:10239", "STY:T005" ], @@ -26981,6 +26876,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Virus", "@type": "ClassDefinition" }, { @@ -26989,7 +26885,6 @@ "description": "", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:CellularOrganism", "NCBITaxon:131567" ], "is_a": "OrganismalEntity", @@ -27013,6 +26908,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CellularOrganism", "@type": "ClassDefinition" }, { @@ -27021,7 +26917,6 @@ "description": "A member of the class Mammalia, a clade of endothermic amniotes distinguished from reptiles and birds by the possession of hair, three middle ear bones, mammary glands, and a neocortex", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Mammal", "NCBITaxon:40674", "STY:T015", "NCIT:C14234", @@ -27048,6 +26943,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Mammal", "@type": "ClassDefinition" }, { @@ -27056,7 +26952,6 @@ "description": "A member of the the species Homo sapiens.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Human", "STY:T016", "NCBITaxon:9606", "SIO:000485", @@ -27083,6 +26978,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Human", "@type": "ClassDefinition" }, { @@ -27091,7 +26987,6 @@ "description": "", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Plant", "NCIT:C14258", "STY:T002", "PO:0000003", @@ -27115,6 +27010,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Plant", "@type": "ClassDefinition" }, { @@ -27123,7 +27019,6 @@ "description": "An animal lacking a vertebral column. This group consists of 98% of all animal species.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Invertebrate", "NCIT:C14228", "OMIT:0008565", "FOODON:00002452", @@ -27150,6 +27045,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Invertebrate", "@type": "ClassDefinition" }, { @@ -27158,7 +27054,6 @@ "description": "A sub-phylum of animals consisting of those having a bony or cartilaginous vertebral column.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Vertebrate", "STY:T010", "NCBITaxon:7742", "OMIT:0015545" @@ -27184,6 +27079,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Vertebrate", "@type": "ClassDefinition" }, { @@ -27192,7 +27088,6 @@ "description": "A kingdom of eukaryotic, heterotrophic organisms that live as saprobes or parasites, including mushrooms, yeasts, smuts, molds, etc. They reproduce either sexually or asexually, and have life cycles that range from simple to complex. Filamentous fungi refer to those that grow as multicellular colonies (mushrooms and molds).", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Fungus", "STY:T004", "NCIT:C14209", "FOODON:03411261" @@ -27219,6 +27114,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Fungus", "@type": "ClassDefinition" }, { @@ -27238,7 +27134,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:LifeStage", "UBERON:0000105" ], "narrow_mappings": [ @@ -27262,6 +27157,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/LifeStage", "@type": "ClassDefinition" }, { @@ -27276,7 +27172,6 @@ "organism" ], "exact_mappings": [ - "biolink:IndividualOrganism", "SIO:010000", "STY:T001" ], @@ -27305,6 +27200,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/IndividualOrganism", "@type": "ClassDefinition" }, { @@ -27326,7 +27222,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PopulationOfIndividualOrganisms", "PCO:0000001", "SIO:001061", "STY:T098", @@ -27353,6 +27248,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PopulationOfIndividualOrganisms", "@type": "ClassDefinition" }, { @@ -27363,9 +27259,6 @@ "aliases": [ "phenome" ], - "exact_mappings": [ - "biolink:DiseaseOrPhenotypicFeature" - ], "narrow_mappings": [ "STY:T033" ], @@ -27387,6 +27280,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeature", "union_of": [ "Disease", "PhenotypicFeature" @@ -27428,7 +27322,6 @@ "medical condition" ], "exact_mappings": [ - "biolink:Disease", "MONDO:0000001", "DOID:4", "NCIT:C2991", @@ -27464,6 +27357,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Disease", "@type": "ClassDefinition" }, { @@ -27507,7 +27401,6 @@ "endophenotype" ], "exact_mappings": [ - "biolink:PhenotypicFeature", "UPHENO:0001001", "SIO:010056", "WIKIDATA:Q104053", @@ -27553,6 +27446,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhenotypicFeature", "@type": "ClassDefinition" }, { @@ -27561,7 +27455,6 @@ "description": "A phenotypic feature which is behavioral in nature.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:BehavioralFeature", "NBO:0000243" ], "is_a": "PhenotypicFeature", @@ -27582,6 +27475,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BehavioralFeature", "@type": "ClassDefinition" }, { @@ -27605,7 +27499,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:AnatomicalEntity", "UBERON:0001062", "WIKIDATA:Q4936952", "UMLSSG:ANAT", @@ -27651,6 +27544,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/AnatomicalEntity", "@type": "ClassDefinition" }, { @@ -27672,7 +27566,6 @@ "cell part" ], "exact_mappings": [ - "biolink:CellularComponent", "GO:0005575", "SIO:001400", "WIKIDATA:Q5058355", @@ -27699,6 +27592,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CellularComponent", "@type": "ClassDefinition" }, { @@ -27716,7 +27610,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Cell", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Cell", "GO:0005623", "CL:0000000", "SIO:010001", @@ -27742,6 +27635,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Cell", "@type": "ClassDefinition" }, { @@ -27752,7 +27646,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CellLine", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:CellLine", "CLO:0000031" ], "is_a": "OrganismalEntity", @@ -27776,6 +27669,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CellLine", "@type": "ClassDefinition" }, { @@ -27795,7 +27689,6 @@ "organ" ], "exact_mappings": [ - "biolink:GrossAnatomicalStructure", "UBERON:0010000", "WIKIDATA:Q4936952" ], @@ -27822,6 +27715,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GrossAnatomicalStructure", "@type": "ClassDefinition" }, { @@ -27829,11 +27723,9 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalEntityOrGeneOrGeneProduct", "description": "A union of chemical entities and children, and gene or gene product. This mixin is helpful to use when searching across chemical entities that must include genes and their children as chemical entities.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalEntityOrGeneOrGeneProduct" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalEntityOrGeneOrGeneProduct", "@type": "ClassDefinition" }, { @@ -27841,11 +27733,9 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalEntityOrProteinOrPolypeptide", "description": "A union of chemical entities and children, and protein and polypeptide. This mixin is helpful to use when searching across chemical entities that must include genes and their children as chemical entities.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalEntityOrProteinOrPolypeptide" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalEntityOrProteinOrPolypeptide", "@type": "ClassDefinition" }, { @@ -27853,14 +27743,12 @@ "definition_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineMixin", "description": "A union of gene locus, gene product, and macromolecular complex. These are the basic units of function in a cell. They either carry out individual biological activities, or they encode molecules which do this.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MacromolecularMachineMixin" - ], "mixin": true, "slots": [ "macromolecular_machine_mixin_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineMixin", "@type": "ClassDefinition" }, { @@ -27872,15 +27760,13 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneOrGeneProduct", "description": "A union of gene loci or gene products. Frequently an identifier for one will be used as proxy for another", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneOrGeneProduct" - ], "is_a": "MacromolecularMachineMixin", "mixin": true, "slots": [ "macromolecular_machine_mixin_name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneOrGeneProduct", "@type": "ClassDefinition" }, { @@ -27913,7 +27799,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Gene", "SO:0000704", "SIO:010035", "WIKIDATA:Q7187", @@ -27952,6 +27837,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Gene", "@type": "ClassDefinition" }, { @@ -27965,7 +27851,6 @@ "description": "The functional molecular product of a single gene locus. Gene products are either proteins or functional RNA molecules.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:GeneProductMixin", "WIKIDATA:Q424689", "GENO:0000907", "NCIT:C26548" @@ -27978,6 +27863,7 @@ "xref" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneProductMixin", "@type": "ClassDefinition" }, { @@ -27985,9 +27871,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneProductIsoformMixin", "description": "This is an abstract class that can be mixed in with different kinds of gene products to indicate that the gene product is intended to represent a specific isoform rather than a canonical or reference or generic product. The designation of canonical or reference may be arbitrary, or it may represent the superclass of all isoforms.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneProductIsoformMixin" - ], "is_a": "GeneProductMixin", "mixin": true, "slots": [ @@ -27996,6 +27879,7 @@ "xref" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneProductIsoformMixin", "@type": "ClassDefinition" }, { @@ -28014,7 +27898,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:MacromolecularComplex", "GO:0032991", "WIKIDATA:Q22325163" ], @@ -28039,6 +27922,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MacromolecularComplex", "@type": "ClassDefinition" }, { @@ -28046,9 +27930,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/NucleosomeModification", "description": "A chemical modification of a histone protein within a nucleosome octomer or a substitution of a histone with a variant histone isoform. e.g. Histone 4 Lysine 20 methylation (H4K20me), histone variant H2AZ substituting H2A.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:NucleosomeModification" - ], "is_a": "BiologicalEntity", "mixins": [ "GeneProductIsoformMixin", @@ -28073,6 +27954,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/NucleosomeModification", "@type": "ClassDefinition" }, { @@ -28084,7 +27966,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Genome", "SO:0001026", "SIO:000984", "WIKIDATA:Q7020" @@ -28116,6 +27997,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Genome", "@type": "ClassDefinition" }, { @@ -28124,7 +28006,6 @@ "description": "A region of the transcript sequence within a gene which is not removed from the primary RNA transcript by RNA splicing.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Exon", "SO:0000147", "SIO:010445", "WIKIDATA:Q373027" @@ -28147,6 +28028,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Exon", "@type": "ClassDefinition" }, { @@ -28162,7 +28044,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Transcript", "SO:0000673", "SIO:010450", "WIKIDATA:Q7243183", @@ -28186,6 +28067,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Transcript", "@type": "ClassDefinition" }, { @@ -28193,7 +28075,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CodingSequence", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:CodingSequence", "SO:0000316", "SIO:001390" ], @@ -28219,6 +28100,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CodingSequence", "@type": "ClassDefinition" }, { @@ -28239,9 +28121,6 @@ "aliases": [ "amino acid entity" ], - "exact_mappings": [ - "biolink:Polypeptide" - ], "narrow_mappings": [ "SO:0000104", "STY:T116", @@ -28269,6 +28148,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Polypeptide", "@type": "ClassDefinition" }, { @@ -28286,7 +28166,6 @@ "description": "A gene product that is composed of a chain of amino acid sequences and is produced by ribosome-mediated translation of mRNA", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Protein", "PR:000000001", "SIO:010043", "WIKIDATA:Q8054" @@ -28319,6 +28198,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Protein", "@type": "ClassDefinition" }, { @@ -28335,9 +28215,6 @@ "aliases": [ "proteoform" ], - "exact_mappings": [ - "biolink:ProteinIsoform" - ], "is_a": "Protein", "mixins": [ "GeneProductIsoformMixin" @@ -28359,6 +28236,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ProteinIsoform", "@type": "ClassDefinition" }, { @@ -28367,7 +28245,6 @@ "description": "A conserved part of protein sequence and (tertiary) structure that can evolve, function, and exist independently of the rest of the protein chain. Protein domains maintain their structure and function independently of the proteins in which they are found. e.g. an SH3 domain.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ProteinDomain", "NCIT:C13379", "SIO:001379", "UMLS:C1514562" @@ -28395,6 +28272,7 @@ "has_gene_or_gene_product" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ProteinDomain", "@type": "ClassDefinition" }, { @@ -28402,9 +28280,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PosttranslationalModification", "description": "A chemical modification of a polypeptide or protein that occurs after translation. e.g. polypeptide cleavage to form separate proteins, methylation or acetylation of histone tail amino acids, protein ubiquitination.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PosttranslationalModification" - ], "is_a": "BiologicalEntity", "mixins": [ "GeneProductIsoformMixin" @@ -28426,6 +28301,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PosttranslationalModification", "@type": "ClassDefinition" }, { @@ -28433,7 +28309,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ProteinFamily", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ProteinFamily", "NCIT:C26004", "WIKIDATA:Q2278983" ], @@ -28465,6 +28340,7 @@ "has_gene_or_gene_product" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ProteinFamily", "@type": "ClassDefinition" }, { @@ -28475,9 +28351,6 @@ "aliases": [ "consensus sequence" ], - "exact_mappings": [ - "biolink:NucleicAcidSequenceMotif" - ], "is_a": "BiologicalEntity", "slots": [ "id", @@ -28496,6 +28369,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/NucleicAcidSequenceMotif", "@type": "ClassDefinition" }, { @@ -28506,7 +28380,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/RNAProduct", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:RNAProduct", "CHEBI:33697", "WIKIDATA:Q11053" ], @@ -28531,6 +28404,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RNAProduct", "@type": "ClassDefinition" }, { @@ -28541,9 +28415,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/RNAProductIsoform", "description": "Represents a protein that is a specific isoform of the canonical or reference RNA", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:RNAProductIsoform" - ], "is_a": "RNAProduct", "mixins": [ "GeneProductIsoformMixin" @@ -28565,6 +28436,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/RNAProductIsoform", "@type": "ClassDefinition" }, { @@ -28577,7 +28449,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/NoncodingRNAProduct", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:NoncodingRNAProduct", "SO:0000655", "SIO:001235" ], @@ -28599,6 +28470,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/NoncodingRNAProduct", "@type": "ClassDefinition" }, { @@ -28614,7 +28486,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:MicroRNA", "SO:0000276", "SIO:001397", "WIKIDATA:Q310899" @@ -28637,6 +28508,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MicroRNA", "@type": "ClassDefinition" }, { @@ -28657,7 +28529,6 @@ "RNAi" ], "exact_mappings": [ - "biolink:SiRNA", "SO:0000646", "WIKIDATA:Q203221" ], @@ -28679,6 +28550,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SiRNA", "@type": "ClassDefinition" }, { @@ -28686,14 +28558,12 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneGroupingMixin", "description": "any grouping of multiple genes or gene products", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneGroupingMixin" - ], "mixin": true, "slots": [ "has_gene_or_gene_product" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneGroupingMixin", "@type": "ClassDefinition" }, { @@ -28731,7 +28601,6 @@ "protein family" ], "exact_mappings": [ - "biolink:GeneFamily", "NCIT:C26004", "WIKIDATA:Q2278983" ], @@ -28763,6 +28632,7 @@ "has_gene_or_gene_product" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneFamily", "@type": "ClassDefinition" }, { @@ -28770,7 +28640,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Zygosity", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Zygosity", "GENO:0000133" ], "is_a": "Attribute", @@ -28792,6 +28661,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Zygosity", "@type": "ClassDefinition" }, { @@ -28810,7 +28680,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Genotype", "GENO:0000536", "SIO:001079" ], @@ -28839,6 +28708,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Genotype", "@type": "ClassDefinition" }, { @@ -28847,7 +28717,6 @@ "description": "A set of zero or more Alleles on a single instance of a Sequence[VMC]", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Haplotype", "GENO:0000871", "SO:0001024", "VMC:Haplotype" @@ -28876,6 +28745,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Haplotype", "@type": "ClassDefinition" }, { @@ -28927,7 +28797,6 @@ "allele" ], "exact_mappings": [ - "biolink:SequenceVariant", "WIKIDATA:Q15304597" ], "close_mappings": [ @@ -28963,6 +28832,7 @@ "sequence_variant_id" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SequenceVariant", "@type": "ClassDefinition" }, { @@ -28976,7 +28846,6 @@ "snp" ], "exact_mappings": [ - "biolink:Snv", "SO:0001483" ], "is_a": "SequenceVariant", @@ -28999,6 +28868,7 @@ "sequence_variant_id" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Snv", "@type": "ClassDefinition" }, { @@ -29013,7 +28883,6 @@ "sequence targeting reagent" ], "exact_mappings": [ - "biolink:ReagentTargetedGene", "GENO:0000504" ], "is_a": "BiologicalEntity", @@ -29040,6 +28909,7 @@ "has_biological_sequence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ReagentTargetedGene", "@type": "ClassDefinition" }, { @@ -29048,7 +28918,6 @@ "description": "Attributes relating to a clinical manifestation", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ClinicalAttribute", "STY:T201" ], "is_a": "Attribute", @@ -29070,6 +28939,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalAttribute", "@type": "ClassDefinition" }, { @@ -29078,7 +28948,6 @@ "description": "A clinical measurement is a special kind of attribute which results from a laboratory observation from a subject individual or sample. Measurements can be connected to their subject by the 'has attribute' slot.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ClinicalMeasurement", "EFO:0001444" ], "is_a": "ClinicalAttribute", @@ -29100,6 +28969,7 @@ "clinical_measurement_has_attribute_type" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalMeasurement", "@type": "ClassDefinition" }, { @@ -29107,9 +28977,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ClinicalModifier", "description": "Used to characterize and specify the phenotypic abnormalities defined in the phenotypic abnormality sub-ontology, with respect to severity, laterality, and other aspects", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ClinicalModifier" - ], "is_a": "ClinicalAttribute", "slots": [ "id", @@ -29129,6 +28996,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalModifier", "@type": "ClassDefinition" }, { @@ -29137,7 +29005,6 @@ "description": "The course a disease typically takes from its onset, progression in time, and eventual resolution or death of the affected individual", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ClinicalCourse", "HP:0031797" ], "is_a": "ClinicalAttribute", @@ -29159,6 +29026,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalCourse", "@type": "ClassDefinition" }, { @@ -29170,7 +29038,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Onset", "HP:0003674" ], "is_a": "ClinicalCourse", @@ -29192,6 +29059,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Onset", "@type": "ClassDefinition" }, { @@ -29199,9 +29067,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ClinicalEntity", "description": "Any entity or process that exists in the clinical domain and outside the biological realm. Diseases are placed under biological entities", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ClinicalEntity" - ], "is_a": "NamedThing", "slots": [ "id", @@ -29218,15 +29083,13 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalEntity", "@type": "ClassDefinition" }, { "name": "ClinicalTrial", "definition_uri": "https://w3id.org/biolink/vocab/ClinicalTrial", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ClinicalTrial" - ], "is_a": "ClinicalEntity", "slots": [ "id", @@ -29243,15 +29106,13 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalTrial", "@type": "ClassDefinition" }, { "name": "ClinicalIntervention", "definition_uri": "https://w3id.org/biolink/vocab/ClinicalIntervention", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ClinicalIntervention" - ], "is_a": "ClinicalEntity", "slots": [ "id", @@ -29268,6 +29129,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalIntervention", "@type": "ClassDefinition" }, { @@ -29280,9 +29142,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ClinicalFinding", "description": "this category is currently considered broad enough to tag clinical lab measurements and other biological attributes taken as 'clinical traits' with some statistical score, for example, a p value in genetic associations.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ClinicalFinding" - ], "is_a": "PhenotypicFeature", "slots": [ "id", @@ -29301,6 +29160,7 @@ "clinical_finding_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ClinicalFinding", "@type": "ClassDefinition" }, { @@ -29308,7 +29168,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/Hospitalization", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Hospitalization", "SNOMEDCT:32485007", "WIKIDATA:Q3140971" ], @@ -29328,6 +29187,7 @@ "named_thing_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Hospitalization", "@type": "ClassDefinition" }, { @@ -29335,9 +29195,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/SocioeconomicAttribute", "description": "Attributes relating to a socioeconomic manifestation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SocioeconomicAttribute" - ], "is_a": "Attribute", "slots": [ "id", @@ -29357,6 +29214,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SocioeconomicAttribute", "@type": "ClassDefinition" }, { @@ -29368,9 +29226,6 @@ "patient", "proband" ], - "exact_mappings": [ - "biolink:Case" - ], "is_a": "IndividualOrganism", "mixins": [ "SubjectOfInvestigation" @@ -29392,6 +29247,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Case", "@type": "ClassDefinition" }, { @@ -29400,7 +29256,6 @@ "description": "A group of people banded together or treated as a group who share common characteristics. A cohort 'study' is a particular form of longitudinal study that samples a cohort, performing a cross-section at intervals through time.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Cohort", "WIKIDATA:Q1303415" ], "narrow_mappings": [ @@ -29430,6 +29285,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Cohort", "@type": "ClassDefinition" }, { @@ -29445,7 +29301,6 @@ "experimental condition" ], "exact_mappings": [ - "biolink:ExposureEvent", "XCO:0000000" ], "is_a": "OntologyClass", @@ -29455,6 +29310,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ExposureEvent", "@type": "ClassDefinition" }, { @@ -29462,9 +29318,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GenomicBackgroundExposure", "description": "A genomic background exposure is where an individual's specific genomic background of genes, sequence variants or other pre-existing genomic conditions constitute a kind of 'exposure' to the organism, leading to or influencing an outcome.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenomicBackgroundExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent", @@ -29497,6 +29350,7 @@ "in_taxon_label" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenomicBackgroundExposure", "@type": "ClassDefinition" }, { @@ -29505,7 +29359,6 @@ "description": "A pathological (abnormal) structure or process.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PathologicalEntityMixin", "MPATH:0" ], "narrow_mappings": [ @@ -29513,6 +29366,7 @@ ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalEntityMixin", "@type": "ClassDefinition" }, { @@ -29521,7 +29375,6 @@ "description": "A biologic function or a process having an abnormal or deleterious effect at the subcellular, cellular, multicellular, or organismal level.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PathologicalProcess", "OBI:1110122", "NCIT:C16956", "MPATH:596" @@ -29556,6 +29409,7 @@ "enabled_by" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalProcess", "@type": "ClassDefinition" }, { @@ -29563,9 +29417,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PathologicalProcessExposure", "description": "A pathological process, when viewed as an exposure, representing a precondition, leading to or influencing an outcome, e.g. autoimmunity leading to disease.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PathologicalProcessExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent" @@ -29589,6 +29440,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalProcessExposure", "@type": "ClassDefinition" }, { @@ -29597,7 +29449,6 @@ "description": "An anatomical structure with the potential of have an abnormal or deleterious effect at the subcellular, cellular, multicellular, or organismal level.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:PathologicalAnatomicalStructure", "MPATH:603" ], "is_a": "AnatomicalEntity", @@ -29621,6 +29472,7 @@ "organismal_entity_has_attribute" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalAnatomicalStructure", "@type": "ClassDefinition" }, { @@ -29628,9 +29480,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PathologicalAnatomicalExposure", "description": "An abnormal anatomical structure, when viewed as an exposure, representing an precondition, leading to or influencing an outcome, e.g. thrombosis leading to an ischemic disease outcome.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PathologicalAnatomicalExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent" @@ -29654,6 +29503,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalAnatomicalExposure", "@type": "ClassDefinition" }, { @@ -29661,9 +29511,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureExposure", "description": "A disease or phenotypic feature state, when viewed as an exposure, represents an precondition, leading to or influencing an outcome, e.g. HIV predisposing an individual to infections; a relative deficiency of skin pigmentation predisposing an individual to skin cancer.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseOrPhenotypicFeatureExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent", @@ -29688,6 +29535,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureExposure", "@type": "ClassDefinition" }, { @@ -29696,7 +29544,6 @@ "description": "A chemical exposure is an intake of a particular chemical entity.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ChemicalExposure", "ECTO:9000000", "SIO:001399" ], @@ -29723,6 +29570,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalExposure", "@type": "ClassDefinition" }, { @@ -29730,9 +29578,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ComplexChemicalExposure", "description": "A complex chemical exposure is an intake of a chemical mixture (e.g. gasoline), other than a drug.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ComplexChemicalExposure" - ], "is_a": "Attribute", "slots": [ "id", @@ -29752,6 +29597,7 @@ "iri" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ComplexChemicalExposure", "@type": "ClassDefinition" }, { @@ -29765,7 +29611,6 @@ "medication intake" ], "exact_mappings": [ - "biolink:DrugExposure", "ECTO:0000509" ], "broad_mappings": [ @@ -29794,6 +29639,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DrugExposure", "@type": "ClassDefinition" }, { @@ -29801,9 +29647,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DrugToGeneInteractionExposure", "description": "drug to gene interaction exposure is a drug exposure is where the interactions of the drug with specific genes are known to constitute an 'exposure' to the organism, leading to or influencing an outcome.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DrugToGeneInteractionExposure" - ], "is_a": "DrugExposure", "mixins": [ "GeneGroupingMixin" @@ -29828,6 +29671,7 @@ "has_gene_or_gene_product" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DrugToGeneInteractionExposure", "@type": "ClassDefinition" }, { @@ -29840,7 +29684,6 @@ "medical intervention" ], "exact_mappings": [ - "biolink:Treatment", "OGMS:0000090", "SIO:001398" ], @@ -29871,6 +29714,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Treatment", "@type": "ClassDefinition" }, { @@ -29882,9 +29726,6 @@ "viral exposure", "bacterial exposure" ], - "exact_mappings": [ - "biolink:BioticExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent" @@ -29908,6 +29749,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BioticExposure", "@type": "ClassDefinition" }, { @@ -29915,9 +29757,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeographicExposure", "description": "A geographic exposure is a factor relating to geographic proximity to some impactful entity.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeographicExposure" - ], "close_mappings": [ "dcid:GeologicalEvent" ], @@ -29960,6 +29799,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeographicExposure", "@type": "ClassDefinition" }, { @@ -29967,9 +29807,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EnvironmentalExposure", "description": "A environmental exposure is a factor relating to abiotic processes in the environment including sunlight (UV-B), atmospheric (heat, cold, general pollution) and water-born contaminants.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EnvironmentalExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent" @@ -29993,6 +29830,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EnvironmentalExposure", "@type": "ClassDefinition" }, { @@ -30000,9 +29838,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/BehavioralExposure", "description": "A behavioral exposure is a factor relating to behavior impacting an individual.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:BehavioralExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent" @@ -30026,6 +29861,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BehavioralExposure", "@type": "ClassDefinition" }, { @@ -30033,9 +29869,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/SocioeconomicExposure", "description": "A socioeconomic exposure is a factor relating to social and financial status of an affected individual (e.g. poverty).", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SocioeconomicExposure" - ], "is_a": "Attribute", "mixins": [ "ExposureEvent" @@ -30059,6 +29892,7 @@ "timepoint" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SocioeconomicExposure", "@type": "ClassDefinition" }, { @@ -30066,11 +29900,9 @@ "definition_uri": "https://w3id.org/biolink/vocab/Outcome", "description": "An entity that has the role of being the consequence of an exposure event. This is an abstract mixin grouping of various categories of possible biological or non-biological (e.g. clinical) outcomes.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:Outcome" - ], "mixin": true, "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Outcome", "@type": "ClassDefinition" }, { @@ -30078,13 +29910,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/PathologicalProcessOutcome", "description": "An outcome resulting from an exposure event which is the manifestation of a pathological process.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PathologicalProcessOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalProcessOutcome", "@type": "ClassDefinition" }, { @@ -30092,13 +29922,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/PathologicalAnatomicalOutcome", "description": "An outcome resulting from an exposure event which is the manifestation of an abnormal anatomical structure.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PathologicalAnatomicalOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PathologicalAnatomicalOutcome", "@type": "ClassDefinition" }, { @@ -30106,13 +29934,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureOutcome", "description": "Physiological outcomes resulting from an exposure event which is the manifestation of a disease or other characteristic phenotype.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseOrPhenotypicFeatureOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureOutcome", "@type": "ClassDefinition" }, { @@ -30120,13 +29946,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/BehavioralOutcome", "description": "An outcome resulting from an exposure event which is the manifestation of human behavior.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:BehavioralOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BehavioralOutcome", "@type": "ClassDefinition" }, { @@ -30134,13 +29958,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/HospitalizationOutcome", "description": "An outcome resulting from an exposure event which is the increased manifestation of acute (e.g. emergency room visit) or chronic (inpatient) hospitalization.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:HospitalizationOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/HospitalizationOutcome", "@type": "ClassDefinition" }, { @@ -30148,13 +29970,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/MortalityOutcome", "description": "An outcome of death from resulting from an exposure event.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MortalityOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MortalityOutcome", "@type": "ClassDefinition" }, { @@ -30162,9 +29982,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EpidemiologicalOutcome", "description": "An epidemiological outcome, such as societal disease burden, resulting from an exposure event.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EpidemiologicalOutcome" - ], "related_mappings": [ "NCIT:C19291" ], @@ -30172,6 +29989,7 @@ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EpidemiologicalOutcome", "@type": "ClassDefinition" }, { @@ -30179,13 +29997,11 @@ "definition_uri": "https://w3id.org/biolink/vocab/SocioeconomicOutcome", "description": "An general social or economic outcome, such as healthcare costs, utilization, etc., resulting from an exposure event", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SocioeconomicOutcome" - ], "mixins": [ "Outcome" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SocioeconomicOutcome", "@type": "ClassDefinition" }, { @@ -30197,7 +30013,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:Association", "OBAN:association", "rdf:Statement", "owl:Axiom" @@ -30245,15 +30060,13 @@ "association_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/Association", "@type": "ClassDefinition" }, { "name": "ChemicalEntityAssessesNamedThingAssociation", "definition_uri": "https://w3id.org/biolink/vocab/ChemicalEntityAssessesNamedThingAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalEntityAssessesNamedThingAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -30297,6 +30110,7 @@ "chemical_entity_assesses_named_thing_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalEntityAssessesNamedThingAssociation", "@type": "ClassDefinition" }, { @@ -30304,9 +30118,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ContributorAssociation", "description": "Any association between an entity (such as a publication) and various agents that contribute to its realisation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ContributorAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -30350,6 +30161,7 @@ "contributor_association_qualifiers" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ContributorAssociation", "defining_slots": [ "subject", "predicate", @@ -30362,9 +30174,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GenotypeToGenotypePartAssociation", "description": "Any association between one genotype and a genotypic entity that is a sub-component of it", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeToGenotypePartAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -30408,6 +30217,7 @@ "genotype_to_genotype_part_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeToGenotypePartAssociation", "defining_slots": [ "subject", "object" @@ -30419,9 +30229,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GenotypeToGeneAssociation", "description": "Any association between a genotype and a gene. The genotype have have multiple variants in that gene or a single one. There is no assumption of cardinality", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeToGeneAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -30465,6 +30272,7 @@ "genotype_to_gene_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeToGeneAssociation", "defining_slots": [ "subject", "object" @@ -30476,9 +30284,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GenotypeToVariantAssociation", "description": "Any association between a genotype and a sequence variant.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeToVariantAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -30522,6 +30327,7 @@ "genotype_to_variant_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeToVariantAssociation", "defining_slots": [ "subject", "object" @@ -30536,9 +30342,6 @@ "aliases": [ "molecular or genetic interaction" ], - "exact_mappings": [ - "biolink:GeneToGeneAssociation" - ], "is_a": "Association", "abstract": true, "slots": [ @@ -30583,6 +30386,7 @@ "gene_to_gene_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToGeneAssociation", "defining_slots": [ "subject", "object" @@ -30594,9 +30398,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneToGeneHomologyAssociation", "description": "A homology association between two genes. May be orthology (in which case the species of subject and object should differ) or paralogy (in which case the species may be the same)", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToGeneHomologyAssociation" - ], "is_a": "GeneToGeneAssociation", "slots": [ "id", @@ -30640,6 +30441,7 @@ "gene_to_gene_homology_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToGeneHomologyAssociation", "defining_slots": [ "subject", "predicate", @@ -30652,9 +30454,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneToGeneFamilyAssociation", "description": "Set membership of a gene in a family of genes related by common evolutionary ancestry usually inferred by sequence comparisons. The genes in a given family generally share common sequence motifs which generally map onto shared gene product structure-function relationships.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToGeneFamilyAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -30698,6 +30497,7 @@ "gene_to_gene_family_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToGeneFamilyAssociation", "defining_slots": [ "subject", "predicate", @@ -30710,9 +30510,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneExpressionMixin", "description": "Observed gene expression intensity, context (site, stage) and associated phenotypic status within which the expression occurs.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneExpressionMixin" - ], "mixin": true, "slots": [ "gene_expression_mixin_quantifier_qualifier", @@ -30721,6 +30518,7 @@ "phenotypic_state" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneExpressionMixin", "@type": "ClassDefinition" }, { @@ -30728,9 +30526,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneToGeneCoexpressionAssociation", "description": "Indicates that two genes are co-expressed, generally under the same conditions.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToGeneCoexpressionAssociation" - ], "is_a": "GeneToGeneAssociation", "mixins": [ "GeneExpressionMixin" @@ -30781,6 +30576,7 @@ "phenotypic_state" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToGeneCoexpressionAssociation", "defining_slots": [ "subject", "predicate", @@ -30793,9 +30589,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PairwiseGeneToGeneInteraction", "description": "An interaction between two genes or two gene products. May be physical (e.g. protein binding) or genetic (between genes). May be symmetric (e.g. protein interaction) or directed (e.g. phosphorylation)", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PairwiseGeneToGeneInteraction" - ], "narrow_mappings": [ "dcid:ProteinProteinInteraction" ], @@ -30842,6 +30635,7 @@ "pairwise_gene_to_gene_interaction_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PairwiseGeneToGeneInteraction", "defining_slots": [ "subject", "predicate", @@ -30854,9 +30648,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PairwiseMolecularInteraction", "description": "An interaction at the molecular level between two physical entities", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PairwiseMolecularInteraction" - ], "is_a": "PairwiseGeneToGeneInteraction", "slots": [ "iri", @@ -30901,6 +30692,7 @@ "pairwise_molecular_interaction_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PairwiseMolecularInteraction", "defining_slots": [ "subject", "predicate", @@ -30913,9 +30705,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CellLineToEntityAssociationMixin", "description": "An relationship between a cell line and another entity", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CellLineToEntityAssociationMixin" - ], "mixin": true, "slots": [ "cell_line_to_entity_association_mixin_subject", @@ -30923,6 +30712,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CellLineToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -30933,9 +30723,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CellLineToDiseaseOrPhenotypicFeatureAssociation", "description": "An relationship between a cell line and a disease or a phenotype, where the cell line is derived from an individual with that disease or phenotype.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CellLineToDiseaseOrPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "CellLineToEntityAssociationMixin", @@ -30983,6 +30770,7 @@ "cell_line_to_disease_or_phenotypic_feature_association_subject" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CellLineToDiseaseOrPhenotypicFeatureAssociation", "@type": "ClassDefinition" }, { @@ -30990,9 +30778,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalEntityToEntityAssociationMixin", "description": "An interaction between a chemical entity and another entity", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalEntityToEntityAssociationMixin" - ], "mixin": true, "slots": [ "chemical_entity_to_entity_association_mixin_subject", @@ -31000,6 +30785,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalEntityToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -31010,9 +30796,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DrugToEntityAssociationMixin", "description": "An interaction between a drug and another entity", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DrugToEntityAssociationMixin" - ], "is_a": "ChemicalEntityToEntityAssociationMixin", "mixin": true, "slots": [ @@ -31021,6 +30804,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DrugToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -31031,9 +30815,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalToEntityAssociationMixin", "description": "An interaction between a chemical entity and another entity", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalToEntityAssociationMixin" - ], "is_a": "ChemicalEntityToEntityAssociationMixin", "mixin": true, "slots": [ @@ -31042,6 +30823,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -31052,9 +30834,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CaseToEntityAssociationMixin", "description": "An abstract association for use where the case is the subject", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CaseToEntityAssociationMixin" - ], "mixin": true, "slots": [ "case_to_entity_association_mixin_subject", @@ -31062,6 +30841,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CaseToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -31072,9 +30852,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalToChemicalAssociation", "description": "A relationship between two chemical entities. This can encompass actual interactions as well as temporal causal edges, e.g. one chemical converted to another.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalToChemicalAssociation" - ], "is_a": "Association", "mixins": [ "ChemicalToEntityAssociationMixin" @@ -31121,6 +30898,7 @@ "chemical_to_chemical_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalToChemicalAssociation", "defining_slots": [ "subject", "object" @@ -31131,9 +30909,6 @@ "name": "ReactionToParticipantAssociation", "definition_uri": "https://w3id.org/biolink/vocab/ReactionToParticipantAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ReactionToParticipantAssociation" - ], "is_a": "ChemicalToChemicalAssociation", "slots": [ "id", @@ -31180,6 +30955,7 @@ "reaction_to_participant_association_subject" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ReactionToParticipantAssociation", "defining_slots": [ "subject", "predicate", @@ -31191,9 +30967,6 @@ "name": "ReactionToCatalystAssociation", "definition_uri": "https://w3id.org/biolink/vocab/ReactionToCatalystAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ReactionToCatalystAssociation" - ], "is_a": "ReactionToParticipantAssociation", "slots": [ "id", @@ -31240,6 +31013,7 @@ "reaction_to_catalyst_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ReactionToCatalystAssociation", "@type": "ClassDefinition" }, { @@ -31247,9 +31021,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalToChemicalDerivationAssociation", "description": "A causal relationship between two chemical entities, where the subject represents the upstream entity and the object represents the downstream. For any such association there is an implicit reaction: IF R has-input C1 AND R has-output C2 AND R enabled-by P AND R type Reaction THEN C1 derives-into C2 catalyst qualifier P", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalToChemicalDerivationAssociation" - ], "is_a": "ChemicalToChemicalAssociation", "slots": [ "id", @@ -31294,6 +31065,7 @@ "chemical_to_chemical_derivation_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalToChemicalDerivationAssociation", "defining_slots": [ "subject", "predicate", @@ -31306,9 +31078,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalToDiseaseOrPhenotypicFeatureAssociation", "description": "An interaction between a chemical entity and a phenotype or disease, where the presence of the chemical gives rise to or exacerbates the phenotype.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalToDiseaseOrPhenotypicFeatureAssociation" - ], "narrow_mappings": [ "SIO:000993" ], @@ -31359,6 +31128,7 @@ "chemical_to_disease_or_phenotypic_feature_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalToDiseaseOrPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -31370,9 +31140,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation", "description": "This association defines a relationship between a chemical or treatment (or procedure) and a disease or phenotypic feature where the disease or phenotypic feature is a secondary undesirable effect.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "ChemicalToEntityAssociationMixin", @@ -31429,6 +31196,7 @@ "disease_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation", "defining_slots": [ "subject", "predicate", @@ -31441,9 +31209,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalOrDrugOrTreatmentSideEffectDiseaseOrPhenotypicFeatureAssociation", "description": "This association defines a relationship between a chemical or treatment (or procedure) and a disease or phenotypic feature where the disesae or phenotypic feature is a secondary, typically (but not always) undesirable effect.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalOrDrugOrTreatmentSideEffectDiseaseOrPhenotypicFeatureAssociation" - ], "is_a": "ChemicalOrDrugOrTreatmentToDiseaseOrPhenotypicFeatureAssociation", "mixins": [ "ChemicalToEntityAssociationMixin", @@ -31499,6 +31264,7 @@ "chemical_or_drug_or_treatment_side_effect_disease_or_phenotypic_feature_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalOrDrugOrTreatmentSideEffectDiseaseOrPhenotypicFeatureAssociation", "defining_slots": [ "subject", "predicate", @@ -31511,9 +31277,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneToPathwayAssociation", "description": "An interaction between a gene or gene product and a biological process or pathway.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToPathwayAssociation" - ], "is_a": "Association", "mixins": [ "GeneToEntityAssociationMixin" @@ -31560,6 +31323,7 @@ "gene_to_pathway_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToPathwayAssociation", "defining_slots": [ "subject", "object" @@ -31571,9 +31335,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MolecularActivityToPathwayAssociation", "description": "Association that holds the relationship between a reaction and the pathway it participates in.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MolecularActivityToPathwayAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -31617,6 +31378,7 @@ "molecular_activity_to_pathway_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MolecularActivityToPathwayAssociation", "@type": "ClassDefinition" }, { @@ -31625,7 +31387,6 @@ "description": "An interaction between a chemical entity and a biological process or pathway.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ChemicalToPathwayAssociation", "SIO:001250" ], "is_a": "Association", @@ -31674,6 +31435,7 @@ "chemical_to_pathway_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalToPathwayAssociation", "defining_slots": [ "subject", "object" @@ -31685,9 +31447,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/NamedThingAssociatedWithLikelihoodOfNamedThingAssociation", "description": "", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:NamedThingAssociatedWithLikelihoodOfNamedThingAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -31736,6 +31495,7 @@ "population_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/NamedThingAssociatedWithLikelihoodOfNamedThingAssociation", "defining_slots": [ "subject", "subject_aspect_qualifier", @@ -31754,7 +31514,6 @@ "description": "describes a physical interaction between a chemical entity and a gene or gene product. Any biological or chemical effect resulting from such an interaction are out of scope, and covered by the ChemicalAffectsGeneAssociation type (e.g. impact of a chemical on the abundance, activity, structure, etc, of either participant in the interaction)", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:ChemicalGeneInteractionAssociation", "SIO:001257" ], "is_a": "Association", @@ -31811,6 +31570,7 @@ "chemical_gene_interaction_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalGeneInteractionAssociation", "@type": "ClassDefinition" }, { @@ -31824,9 +31584,6 @@ } ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneRegulatesGeneAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -31874,6 +31631,7 @@ "gene_regulates_gene_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneRegulatesGeneAssociation", "@type": "ClassDefinition" }, { @@ -31881,9 +31639,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ProcessRegulatesProcessAssociation", "description": "Describes a regulatory relationship between two genes or gene products.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ProcessRegulatesProcessAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -31927,6 +31682,7 @@ "process_regulates_process_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ProcessRegulatesProcessAssociation", "@type": "ClassDefinition" }, { @@ -31934,9 +31690,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalAffectsGeneAssociation", "description": "Describes an effect that a chemical has on a gene or gene product (e.g. an impact of on its abundance, activity,localization, processing, expression, etc.)", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalAffectsGeneAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -31995,6 +31748,7 @@ "chemical_affects_gene_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalAffectsGeneAssociation", "@type": "ClassDefinition" }, { @@ -32008,9 +31762,6 @@ } ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneAffectsChemicalAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -32070,6 +31821,7 @@ "gene_affects_chemical_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneAffectsChemicalAssociation", "@type": "ClassDefinition" }, { @@ -32077,9 +31829,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DrugToGeneAssociation", "description": "An interaction between a drug and a gene or gene product.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DrugToGeneAssociation" - ], "related_mappings": [ "SIO:001257" ], @@ -32129,6 +31878,7 @@ "drug_to_gene_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DrugToGeneAssociation", "defining_slots": [ "subject", "object" @@ -32140,9 +31890,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MaterialSampleToEntityAssociationMixin", "description": "An association between a material sample and something.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MaterialSampleToEntityAssociationMixin" - ], "mixin": true, "slots": [ "material_sample_to_entity_association_mixin_subject", @@ -32150,6 +31897,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MaterialSampleToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -32160,9 +31908,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MaterialSampleDerivationAssociation", "description": "An association between a material sample and the material entity from which it is derived.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MaterialSampleDerivationAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -32206,6 +31951,7 @@ "material_sample_derivation_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MaterialSampleDerivationAssociation", "defining_slots": [ "subject", "predicate" @@ -32217,9 +31963,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MaterialSampleToDiseaseOrPhenotypicFeatureAssociation", "description": "An association between a material sample and a disease or phenotype.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MaterialSampleToDiseaseOrPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "MaterialSampleToEntityAssociationMixin", @@ -32267,6 +32010,7 @@ "association_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MaterialSampleToDiseaseOrPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -32277,9 +32021,6 @@ "name": "DiseaseToEntityAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/DiseaseToEntityAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseToEntityAssociationMixin" - ], "mixin": true, "slots": [ "disease_to_entity_association_mixin_subject", @@ -32287,6 +32028,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -32297,9 +32039,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EntityToExposureEventAssociationMixin", "description": "An association between some entity and an exposure event.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToExposureEventAssociationMixin" - ], "mixin": true, "slots": [ "subject", @@ -32307,6 +32046,7 @@ "entity_to_exposure_event_association_mixin_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToExposureEventAssociationMixin", "defining_slots": [ "object" ], @@ -32317,9 +32057,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DiseaseToExposureEventAssociation", "description": "An association between an exposure event and a disease.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseToExposureEventAssociation" - ], "is_a": "Association", "mixins": [ "DiseaseToEntityAssociationMixin", @@ -32367,6 +32104,7 @@ "association_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseToExposureEventAssociation", "defining_slots": [ "subject", "object" @@ -32378,9 +32116,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EntityToOutcomeAssociationMixin", "description": "An association between some entity and an outcome", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToOutcomeAssociationMixin" - ], "mixin": true, "slots": [ "subject", @@ -32388,6 +32123,7 @@ "entity_to_outcome_association_mixin_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToOutcomeAssociationMixin", "defining_slots": [ "object" ], @@ -32398,9 +32134,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ExposureEventToOutcomeAssociation", "description": "An association between an exposure event and an outcome.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ExposureEventToOutcomeAssociation" - ], "is_a": "Association", "mixins": [ "EntityToOutcomeAssociationMixin" @@ -32449,6 +32182,7 @@ "temporal_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ExposureEventToOutcomeAssociation", "defining_slots": [ "subject", "object" @@ -32460,9 +32194,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/FrequencyQualifierMixin", "description": "Qualifier for frequency type associations", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:FrequencyQualifierMixin" - ], "mixin": true, "slots": [ "frequency_qualifier", @@ -32471,6 +32202,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/FrequencyQualifierMixin", "@type": "ClassDefinition" }, { @@ -32478,9 +32210,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EntityToFeatureOrDiseaseQualifiersMixin", "description": "Qualifiers for entity to disease or phenotype associations.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToFeatureOrDiseaseQualifiersMixin" - ], "is_a": "FrequencyQualifierMixin", "mixin": true, "slots": [ @@ -32496,6 +32225,7 @@ "disease_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToFeatureOrDiseaseQualifiersMixin", "@type": "ClassDefinition" }, { @@ -32503,9 +32233,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/FeatureOrDiseaseQualifiersToEntityMixin", "description": "Qualifiers for disease or phenotype to entity associations.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:FeatureOrDiseaseQualifiersToEntityMixin" - ], "is_a": "FrequencyQualifierMixin", "mixin": true, "slots": [ @@ -32520,15 +32247,13 @@ "qualified_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/FeatureOrDiseaseQualifiersToEntityMixin", "@type": "ClassDefinition" }, { "name": "EntityToPhenotypicFeatureAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/EntityToPhenotypicFeatureAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToPhenotypicFeatureAssociationMixin" - ], "is_a": "EntityToFeatureOrDiseaseQualifiersMixin", "mixin": true, "mixins": [ @@ -32555,6 +32280,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToPhenotypicFeatureAssociationMixin", "defining_slots": [ "subject", "predicate", @@ -32566,9 +32292,6 @@ "name": "PhenotypicFeatureToEntityAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/PhenotypicFeatureToEntityAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PhenotypicFeatureToEntityAssociationMixin" - ], "is_a": "FeatureOrDiseaseQualifiersToEntityMixin", "mixin": true, "mixins": [ @@ -32591,6 +32314,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhenotypicFeatureToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -32601,9 +32325,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PhenotypicFeatureToPhenotypicFeatureAssociation", "description": "Association between two concept nodes of phenotypic character, qualified by the predicate used. This association may typically be used to specify 'similar_to' or 'member_of' relationships.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PhenotypicFeatureToPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "PhenotypicFeatureToEntityAssociationMixin", @@ -32666,6 +32387,7 @@ "anatomical_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhenotypicFeatureToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "predicate", @@ -32681,9 +32403,6 @@ "model_organism_database" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:InformationContentEntityToNamedThingAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -32727,6 +32446,7 @@ "information_content_entity_to_named_thing_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/InformationContentEntityToNamedThingAssociation", "defining_slots": [ "subject", "object" @@ -32738,9 +32458,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/EntityToDiseaseAssociationMixin", "description": "mixin class for any association whose object (target node) is a disease", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToDiseaseAssociationMixin" - ], "is_a": "EntityToFeatureOrDiseaseQualifiersMixin", "mixin": true, "slots": [ @@ -32756,6 +32473,7 @@ "entity_to_disease_association_mixin_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToDiseaseAssociationMixin", "defining_slots": [ "object" ], @@ -32765,9 +32483,6 @@ "name": "DiseaseOrPhenotypicFeatureToEntityAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureToEntityAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseOrPhenotypicFeatureToEntityAssociationMixin" - ], "mixin": true, "slots": [ "disease_or_phenotypic_feature_to_entity_association_mixin_subject", @@ -32775,6 +32490,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -32785,9 +32501,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureToLocationAssociation", "description": "An association between either a disease or a phenotypic feature and an anatomical entity, where the disease/feature manifests in that site.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseOrPhenotypicFeatureToLocationAssociation" - ], "is_a": "Association", "mixins": [ "DiseaseOrPhenotypicFeatureToEntityAssociationMixin" @@ -32834,6 +32547,7 @@ "disease_or_phenotypic_feature_to_location_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureToLocationAssociation", "@type": "ClassDefinition" }, { @@ -32841,9 +32555,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureToGeneticInheritanceAssociation", "description": "An association between either a disease or a phenotypic feature and its mode of (genetic) inheritance.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseOrPhenotypicFeatureToGeneticInheritanceAssociation" - ], "is_a": "Association", "mixins": [ "DiseaseOrPhenotypicFeatureToEntityAssociationMixin" @@ -32890,15 +32601,13 @@ "disease_or_phenotypic_feature_to_genetic_inheritance_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseOrPhenotypicFeatureToGeneticInheritanceAssociation", "@type": "ClassDefinition" }, { "name": "EntityToDiseaseOrPhenotypicFeatureAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/EntityToDiseaseOrPhenotypicFeatureAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToDiseaseOrPhenotypicFeatureAssociationMixin" - ], "mixin": true, "slots": [ "subject", @@ -32906,6 +32615,7 @@ "entity_to_disease_or_phenotypic_feature_association_mixin_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToDiseaseOrPhenotypicFeatureAssociationMixin", "defining_slots": [ "object" ], @@ -32915,9 +32625,6 @@ "name": "GenotypeToEntityAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/GenotypeToEntityAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeToEntityAssociationMixin" - ], "mixin": true, "slots": [ "genotype_to_entity_association_mixin_subject", @@ -32925,6 +32632,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -32935,9 +32643,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GenotypeToPhenotypicFeatureAssociation", "description": "Any association between one genotype and a phenotypic feature, where having the genotype confers the phenotype, either in isolation or through environment", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeToPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "EntityToPhenotypicFeatureAssociationMixin", @@ -33000,6 +32705,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -33011,9 +32717,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ExposureEventToPhenotypicFeatureAssociation", "description": "Any association between an environment and a phenotypic feature, where being in the environment influences the phenotype.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ExposureEventToPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "EntityToPhenotypicFeatureAssociationMixin" @@ -33075,6 +32778,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ExposureEventToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -33086,9 +32790,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/DiseaseToPhenotypicFeatureAssociation", "description": "An association between a disease and a phenotypic feature in which the phenotypic feature is associated with the disease in some way.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DiseaseToPhenotypicFeatureAssociation" - ], "close_mappings": [ "dcid:DiseaseSymptomAssociation" ], @@ -33156,6 +32857,7 @@ "anatomical_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DiseaseToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -33167,9 +32869,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/CaseToPhenotypicFeatureAssociation", "description": "An association between a case (e.g. individual patient) and a phenotypic feature in which the individual has or has had the phenotype.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CaseToPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "EntityToPhenotypicFeatureAssociationMixin", @@ -33232,6 +32931,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CaseToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -33243,9 +32943,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/BehaviorToBehavioralFeatureAssociation", "description": "An association between an mixture behavior and a behavioral feature manifested by the individual exhibited or has exhibited the behavior.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:BehaviorToBehavioralFeatureAssociation" - ], "is_a": "Association", "mixins": [ "EntityToPhenotypicFeatureAssociationMixin" @@ -33307,6 +33004,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/BehaviorToBehavioralFeatureAssociation", "defining_slots": [ "subject", "object" @@ -33317,9 +33015,6 @@ "name": "GeneToEntityAssociationMixin", "definition_uri": "https://w3id.org/biolink/vocab/GeneToEntityAssociationMixin", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToEntityAssociationMixin" - ], "mixin": true, "slots": [ "gene_to_entity_association_mixin_subject", @@ -33327,6 +33022,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -33343,9 +33039,6 @@ } ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantToEntityAssociationMixin" - ], "mixin": true, "slots": [ "variant_to_entity_association_mixin_subject", @@ -33353,6 +33046,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantToEntityAssociationMixin", "defining_slots": [ "subject" ], @@ -33367,9 +33061,6 @@ "if the relationship of the statement using this predicate is statistical in nature, please use `associated with likelihood` or one of its children." ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToDiseaseOrPhenotypicFeatureAssociation" - ], "narrow_mappings": [ "WBVocab:Gene-Phenotype-Association", "dcid:DiseaseGeneAssociation", @@ -33437,6 +33128,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToDiseaseOrPhenotypicFeatureAssociation", "@type": "ClassDefinition" }, { @@ -33444,7 +33136,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneToPhenotypicFeatureAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:GeneToPhenotypicFeatureAssociation", "WBVocab:Gene-Phenotype-Association" ], "is_a": "GeneToDiseaseOrPhenotypicFeatureAssociation", @@ -33509,6 +33200,7 @@ "gene_to_phenotypic_feature_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -33523,7 +33215,6 @@ ], "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:GeneToDiseaseAssociation", "SIO:000983" ], "close_mappings": [ @@ -33591,6 +33282,7 @@ "gene_to_disease_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToDiseaseAssociation", "defining_slots": [ "subject", "object" @@ -33601,9 +33293,6 @@ "name": "CausalGeneToDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/CausalGeneToDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CausalGeneToDiseaseAssociation" - ], "is_a": "GeneToDiseaseAssociation", "mixins": [ "EntityToDiseaseAssociationMixin", @@ -33666,6 +33355,7 @@ "causal_gene_to_disease_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CausalGeneToDiseaseAssociation", "defining_slots": [ "subject", "object" @@ -33676,9 +33366,6 @@ "name": "CorrelatedGeneToDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/CorrelatedGeneToDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CorrelatedGeneToDiseaseAssociation" - ], "is_a": "GeneToDiseaseAssociation", "mixins": [ "EntityToDiseaseAssociationMixin", @@ -33741,6 +33428,7 @@ "correlated_gene_to_disease_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CorrelatedGeneToDiseaseAssociation", "defining_slots": [ "subject", "object" @@ -33751,9 +33439,6 @@ "name": "DruggableGeneToDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/DruggableGeneToDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:DruggableGeneToDiseaseAssociation" - ], "is_a": "GeneToDiseaseAssociation", "mixins": [ "EntityToDiseaseAssociationMixin", @@ -33816,6 +33501,7 @@ "druggable_gene_to_disease_association_has_evidence" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/DruggableGeneToDiseaseAssociation", "defining_slots": [ "subject", "object", @@ -33827,9 +33513,6 @@ "name": "PhenotypicFeatureToDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/PhenotypicFeatureToDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PhenotypicFeatureToDiseaseAssociation" - ], "is_a": "Association", "mixins": [ "EntityToDiseaseAssociationMixin", @@ -33889,6 +33572,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PhenotypicFeatureToDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -33901,9 +33585,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/VariantToGeneAssociation", "description": "An association between a variant and a gene, where the variant has a genetic association with the gene (i.e. is in linkage disequilibrium)", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantToGeneAssociation" - ], "is_a": "Association", "mixins": [ "VariantToEntityAssociationMixin" @@ -33950,6 +33631,7 @@ "variant_to_gene_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantToGeneAssociation", "defining_slots": [ "subject", "predicate", @@ -33962,9 +33644,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/VariantToGeneExpressionAssociation", "description": "An association between a variant and expression of a gene (i.e. e-QTL)", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantToGeneExpressionAssociation" - ], "is_a": "VariantToGeneAssociation", "mixins": [ "GeneExpressionMixin" @@ -34015,6 +33694,7 @@ "phenotypic_state" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantToGeneExpressionAssociation", "defining_slots": [ "subject", "predicate", @@ -34027,9 +33707,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/VariantToPopulationAssociation", "description": "An association between a variant and a population, where the variant has particular frequency in the population", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantToPopulationAssociation" - ], "is_a": "Association", "mixins": [ "VariantToEntityAssociationMixin", @@ -34083,6 +33760,7 @@ "frequency_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantToPopulationAssociation", "defining_slots": [ "subject", "object" @@ -34094,9 +33772,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/PopulationToPopulationAssociation", "description": "An association between a two populations", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:PopulationToPopulationAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -34140,6 +33815,7 @@ "population_to_population_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/PopulationToPopulationAssociation", "defining_slots": [ "subject", "object" @@ -34150,9 +33826,6 @@ "name": "VariantToPhenotypicFeatureAssociation", "definition_uri": "https://w3id.org/biolink/vocab/VariantToPhenotypicFeatureAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantToPhenotypicFeatureAssociation" - ], "is_a": "Association", "mixins": [ "VariantToEntityAssociationMixin", @@ -34215,6 +33888,7 @@ "has_percentage" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -34228,9 +33902,6 @@ "TODO decide no how to model pathogenicity" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantToDiseaseAssociation" - ], "is_a": "Association", "mixins": [ "VariantToEntityAssociationMixin", @@ -34285,6 +33956,7 @@ "disease_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantToDiseaseAssociation", "defining_slots": [ "subject", "object" @@ -34298,9 +33970,6 @@ "TODO decide no how to model pathogenicity" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeToDiseaseAssociation" - ], "is_a": "Association", "mixins": [ "GenotypeToEntityAssociationMixin", @@ -34355,6 +34024,7 @@ "disease_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeToDiseaseAssociation", "defining_slots": [ "subject", "object" @@ -34366,9 +34036,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ModelToDiseaseAssociationMixin", "description": "This mixin is used for any association class for which the subject (source node) plays the role of a 'model', in that it recapitulates some features of the disease in a way that is useful for studying the disease outside a patient carrying the disease", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ModelToDiseaseAssociationMixin" - ], "mixin": true, "slots": [ "model_to_disease_association_mixin_subject", @@ -34376,15 +34043,13 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ModelToDiseaseAssociationMixin", "@type": "ClassDefinition" }, { "name": "GeneAsAModelOfDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/GeneAsAModelOfDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneAsAModelOfDiseaseAssociation" - ], "is_a": "GeneToDiseaseAssociation", "mixins": [ "ModelToDiseaseAssociationMixin", @@ -34447,6 +34112,7 @@ "gene_as_a_model_of_disease_association_subject" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneAsAModelOfDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -34458,9 +34124,6 @@ "name": "VariantAsAModelOfDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/VariantAsAModelOfDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:VariantAsAModelOfDiseaseAssociation" - ], "is_a": "VariantToDiseaseAssociation", "mixins": [ "ModelToDiseaseAssociationMixin", @@ -34515,6 +34178,7 @@ "variant_as_a_model_of_disease_association_subject" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/VariantAsAModelOfDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -34526,9 +34190,6 @@ "name": "GenotypeAsAModelOfDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/GenotypeAsAModelOfDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GenotypeAsAModelOfDiseaseAssociation" - ], "is_a": "GenotypeToDiseaseAssociation", "mixins": [ "ModelToDiseaseAssociationMixin", @@ -34583,6 +34244,7 @@ "genotype_as_a_model_of_disease_association_subject" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenotypeAsAModelOfDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -34594,9 +34256,6 @@ "name": "CellLineAsAModelOfDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/CellLineAsAModelOfDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:CellLineAsAModelOfDiseaseAssociation" - ], "is_a": "CellLineToDiseaseOrPhenotypicFeatureAssociation", "mixins": [ "ModelToDiseaseAssociationMixin", @@ -34651,6 +34310,7 @@ "disease_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/CellLineAsAModelOfDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -34662,9 +34322,6 @@ "name": "OrganismalEntityAsAModelOfDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/OrganismalEntityAsAModelOfDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismalEntityAsAModelOfDiseaseAssociation" - ], "is_a": "Association", "mixins": [ "ModelToDiseaseAssociationMixin", @@ -34719,6 +34376,7 @@ "disease_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismalEntityAsAModelOfDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -34730,9 +34388,6 @@ "name": "OrganismToOrganismAssociation", "definition_uri": "https://w3id.org/biolink/vocab/OrganismToOrganismAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismToOrganismAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -34776,6 +34431,7 @@ "organism_to_organism_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismToOrganismAssociation", "defining_slots": [ "subject", "predicate", @@ -34787,9 +34443,6 @@ "name": "TaxonToTaxonAssociation", "definition_uri": "https://w3id.org/biolink/vocab/TaxonToTaxonAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:TaxonToTaxonAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -34833,6 +34486,7 @@ "taxon_to_taxon_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/TaxonToTaxonAssociation", "defining_slots": [ "subject", "predicate", @@ -34844,9 +34498,6 @@ "name": "GeneHasVariantThatContributesToDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/GeneHasVariantThatContributesToDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneHasVariantThatContributesToDiseaseAssociation" - ], "is_a": "GeneToDiseaseAssociation", "slots": [ "id", @@ -34906,6 +34557,7 @@ "gene_has_variant_that_contributes_to_disease_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneHasVariantThatContributesToDiseaseAssociation", "defining_slots": [ "subject", "predicate", @@ -34924,9 +34576,6 @@ "see_also": [ "https://github.com/monarch-initiative/ingest-artifacts/tree/master/sources/BGee" ], - "exact_mappings": [ - "biolink:GeneToExpressionSiteAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -34972,6 +34621,7 @@ "gene_to_expression_site_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToExpressionSiteAssociation", "defining_slots": [ "subject", "predicate", @@ -34987,9 +34637,6 @@ "An alternate way to model the same information could be via a qualifier" ], "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SequenceVariantModulatesTreatmentAssociation" - ], "is_a": "Association", "abstract": true, "slots": [ @@ -35034,6 +34681,7 @@ "sequence_variant_modulates_treatment_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SequenceVariantModulatesTreatmentAssociation", "defining_slots": [ "subject", "object" @@ -35045,9 +34693,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/FunctionalAssociation", "description": "An association between a macromolecular machine mixin (gene, gene product or complex of gene products) and either a molecular activity, a biological process or a cellular location in which a function is executed.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:FunctionalAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35091,6 +34736,7 @@ "functional_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/FunctionalAssociation", "@type": "ClassDefinition" }, { @@ -35098,9 +34744,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToEntityAssociationMixin", "description": "an association which has a macromolecular machine mixin as a subject", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MacromolecularMachineToEntityAssociationMixin" - ], "mixin": true, "slots": [ "macromolecular_machine_to_entity_association_mixin_subject", @@ -35109,6 +34752,7 @@ "species_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToEntityAssociationMixin", "@type": "ClassDefinition" }, { @@ -35116,9 +34760,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToMolecularActivityAssociation", "description": "A functional association between a macromolecular machine (gene, gene product or complex) and a molecular activity (as represented in the GO molecular function branch), where the entity carries out the activity, or contributes to its execution.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MacromolecularMachineToMolecularActivityAssociation" - ], "is_a": "FunctionalAssociation", "mixins": [ "MacromolecularMachineToEntityAssociationMixin" @@ -35166,6 +34807,7 @@ "species_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToMolecularActivityAssociation", "@type": "ClassDefinition" }, { @@ -35173,9 +34815,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToBiologicalProcessAssociation", "description": "A functional association between a macromolecular machine (gene, gene product or complex) and a biological process or pathway (as represented in the GO biological process branch), where the entity carries out some part of the process, regulates it, or acts upstream of it.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MacromolecularMachineToBiologicalProcessAssociation" - ], "is_a": "FunctionalAssociation", "mixins": [ "MacromolecularMachineToEntityAssociationMixin" @@ -35223,6 +34862,7 @@ "species_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToBiologicalProcessAssociation", "@type": "ClassDefinition" }, { @@ -35230,9 +34870,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToCellularComponentAssociation", "description": "A functional association between a macromolecular machine (gene, gene product or complex) and a cellular component (as represented in the GO cellular component branch), where the entity carries out its function in the cellular component.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MacromolecularMachineToCellularComponentAssociation" - ], "is_a": "FunctionalAssociation", "mixins": [ "MacromolecularMachineToEntityAssociationMixin" @@ -35280,6 +34917,7 @@ "species_context_qualifier" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MacromolecularMachineToCellularComponentAssociation", "@type": "ClassDefinition" }, { @@ -35287,9 +34925,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MolecularActivityToChemicalEntityAssociation", "description": "Added in response to capturing relationship between microbiome activities as measured via measurements of blood analytes as collected via blood and stool samples", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MolecularActivityToChemicalEntityAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35333,6 +34968,7 @@ "molecular_activity_to_chemical_entity_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MolecularActivityToChemicalEntityAssociation", "@type": "ClassDefinition" }, { @@ -35340,9 +34976,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/MolecularActivityToMolecularActivityAssociation", "description": "Added in response to capturing relationship between microbiome activities as measured via measurements of blood analytes as collected via blood and stool samples", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:MolecularActivityToMolecularActivityAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35386,6 +35019,7 @@ "molecular_activity_to_molecular_activity_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/MolecularActivityToMolecularActivityAssociation", "@type": "ClassDefinition" }, { @@ -35396,7 +35030,6 @@ "functional association" ], "exact_mappings": [ - "biolink:GeneToGoTermAssociation", "WBVocab:Gene-GO-Association" ], "is_a": "FunctionalAssociation", @@ -35442,6 +35075,7 @@ "gene_to_go_term_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToGoTermAssociation", "defining_slots": [ "subject", "object" @@ -35452,9 +35086,6 @@ "name": "EntityToDiseaseAssociation", "definition_uri": "https://w3id.org/biolink/vocab/EntityToDiseaseAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToDiseaseAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35500,6 +35131,7 @@ "max_research_phase" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToDiseaseAssociation", "defining_slots": [ "subject", "object" @@ -35510,9 +35142,6 @@ "name": "EntityToPhenotypicFeatureAssociation", "definition_uri": "https://w3id.org/biolink/vocab/EntityToPhenotypicFeatureAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:EntityToPhenotypicFeatureAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35558,6 +35187,7 @@ "max_research_phase" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/EntityToPhenotypicFeatureAssociation", "defining_slots": [ "subject", "object" @@ -35569,9 +35199,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/SequenceAssociation", "description": "An association between a sequence feature and a nucleic acid entity it is localized to.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:SequenceAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35615,6 +35242,7 @@ "association_category" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SequenceAssociation", "@type": "ClassDefinition" }, { @@ -35623,7 +35251,6 @@ "description": "A relationship between a sequence feature and a nucleic acid entity it is localized to. The reference entity may be a chromosome, chromosome region or information entity such as a contig.", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:GenomicSequenceLocalization", "dcid:GenomeAnnotation" ], "broad_mappings": [ @@ -35677,6 +35304,7 @@ "genomic_sequence_localization_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GenomicSequenceLocalization", "@type": "ClassDefinition" }, { @@ -35685,7 +35313,6 @@ "description": "For example, a particular exon is part of a particular transcript or gene", "from_schema": "https://w3id.org/biolink/biolink-model", "exact_mappings": [ - "biolink:SequenceFeatureRelationship", "CHADO:feature_relationship" ], "is_a": "Association", @@ -35731,6 +35358,7 @@ "sequence_feature_relationship_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/SequenceFeatureRelationship", "defining_slots": [ "subject", "object" @@ -35742,9 +35370,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/TranscriptToGeneRelationship", "description": "A gene is a collection of transcripts", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:TranscriptToGeneRelationship" - ], "is_a": "SequenceFeatureRelationship", "slots": [ "id", @@ -35788,6 +35413,7 @@ "transcript_to_gene_relationship_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/TranscriptToGeneRelationship", "defining_slots": [ "subject", "object" @@ -35799,9 +35425,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/GeneToGeneProductRelationship", "description": "A gene is transcribed and potentially translated to a gene product", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:GeneToGeneProductRelationship" - ], "is_a": "SequenceFeatureRelationship", "slots": [ "id", @@ -35845,6 +35468,7 @@ "gene_to_gene_product_relationship_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/GeneToGeneProductRelationship", "defining_slots": [ "subject", "object" @@ -35856,9 +35480,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ExonToTranscriptRelationship", "description": "A transcript is formed from multiple exons", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ExonToTranscriptRelationship" - ], "is_a": "SequenceFeatureRelationship", "slots": [ "id", @@ -35902,6 +35523,7 @@ "exon_to_transcript_relationship_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ExonToTranscriptRelationship", "defining_slots": [ "subject", "object" @@ -35913,9 +35535,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/ChemicalEntityOrGeneOrGeneProductRegulatesGeneAssociation", "description": "A regulatory relationship between two genes", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:ChemicalEntityOrGeneOrGeneProductRegulatesGeneAssociation" - ], "is_a": "Association", "slots": [ "id", @@ -35960,15 +35579,13 @@ "chemical_entity_or_gene_or_gene_product_regulates_gene_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/ChemicalEntityOrGeneOrGeneProductRegulatesGeneAssociation", "@type": "ClassDefinition" }, { "name": "AnatomicalEntityToAnatomicalEntityAssociation", "definition_uri": "https://w3id.org/biolink/vocab/AnatomicalEntityToAnatomicalEntityAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:AnatomicalEntityToAnatomicalEntityAssociation" - ], "is_a": "Association", "abstract": true, "slots": [ @@ -36013,6 +35630,7 @@ "anatomical_entity_to_anatomical_entity_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/AnatomicalEntityToAnatomicalEntityAssociation", "defining_slots": [ "subject", "object" @@ -36024,9 +35642,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/AnatomicalEntityToAnatomicalEntityPartOfAssociation", "description": "A relationship between two anatomical entities where the relationship is mereological, i.e the two entities are related by parthood. This includes relationships between cellular components and cells, between cells and tissues, tissues and whole organisms", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:AnatomicalEntityToAnatomicalEntityPartOfAssociation" - ], "is_a": "AnatomicalEntityToAnatomicalEntityAssociation", "slots": [ "id", @@ -36070,6 +35685,7 @@ "anatomical_entity_to_anatomical_entity_part_of_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/AnatomicalEntityToAnatomicalEntityPartOfAssociation", "defining_slots": [ "predicate" ], @@ -36080,9 +35696,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/AnatomicalEntityToAnatomicalEntityOntogenicAssociation", "description": "A relationship between two anatomical entities where the relationship is ontogenic, i.e. the two entities are related by development. A number of different relationship types can be used to specify the precise nature of the relationship.", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:AnatomicalEntityToAnatomicalEntityOntogenicAssociation" - ], "is_a": "AnatomicalEntityToAnatomicalEntityAssociation", "slots": [ "id", @@ -36126,6 +35739,7 @@ "anatomical_entity_to_anatomical_entity_ontogenic_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/AnatomicalEntityToAnatomicalEntityOntogenicAssociation", "defining_slots": [ "predicate" ], @@ -36136,9 +35750,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToEntityAssociation", "description": "An association between an organism taxon and another entity", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismTaxonToEntityAssociation" - ], "mixin": true, "slots": [ "organism_taxon_to_entity_association_subject", @@ -36146,6 +35757,7 @@ "object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToEntityAssociation", "defining_slots": [ "subject" ], @@ -36156,9 +35768,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToOrganismTaxonAssociation", "description": "A relationship between two organism taxon nodes", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismTaxonToOrganismTaxonAssociation" - ], "is_a": "Association", "abstract": true, "mixins": [ @@ -36206,6 +35815,7 @@ "organism_taxon_to_organism_taxon_association_object" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToOrganismTaxonAssociation", "defining_slots": [ "subject", "object" @@ -36217,9 +35827,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToOrganismTaxonSpecialization", "description": "A child-parent relationship between two taxa. For example: Homo sapiens subclass_of Homo", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismTaxonToOrganismTaxonSpecialization" - ], "is_a": "OrganismTaxonToOrganismTaxonAssociation", "slots": [ "id", @@ -36263,6 +35870,7 @@ "organism_taxon_to_organism_taxon_specialization_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToOrganismTaxonSpecialization", "defining_slots": [ "predicate" ], @@ -36273,9 +35881,6 @@ "definition_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToOrganismTaxonInteraction", "description": "An interaction relationship between two taxa. This may be a symbiotic relationship (encompassing mutualism and parasitism), or it may be non-symbiotic. Example: plague transmitted_by flea; cattle domesticated_by Homo sapiens; plague infects Homo sapiens", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismTaxonToOrganismTaxonInteraction" - ], "is_a": "OrganismTaxonToOrganismTaxonAssociation", "slots": [ "id", @@ -36320,6 +35925,7 @@ "organism_taxon_to_organism_taxon_interaction_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToOrganismTaxonInteraction", "defining_slots": [ "predicate" ], @@ -36329,9 +35935,6 @@ "name": "OrganismTaxonToEnvironmentAssociation", "definition_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToEnvironmentAssociation", "from_schema": "https://w3id.org/biolink/biolink-model", - "exact_mappings": [ - "biolink:OrganismTaxonToEnvironmentAssociation" - ], "is_a": "Association", "abstract": true, "mixins": [ @@ -36379,6 +35982,7 @@ "organism_taxon_to_environment_association_predicate" ], "slot_usage": {}, + "class_uri": "https://w3id.org/biolink/vocab/OrganismTaxonToEnvironmentAssociation", "@type": "ClassDefinition" } ], diff --git a/tests/linkml/test_enhancements/__snapshots__/enumeration/alternatives.yaml b/tests/linkml/test_enhancements/__snapshots__/enumeration/alternatives.yaml index cb8ebe66b2..2397ef8259 100644 --- a/tests/linkml/test_enhancements/__snapshots__/enumeration/alternatives.yaml +++ b/tests/linkml/test_enhancements/__snapshots__/enumeration/alternatives.yaml @@ -480,8 +480,6 @@ classes: definition_uri: http://example.org/test/evidence/AllEnums description: A class that incorporates all of the enumeration examples above from_schema: http://example.org/test/alternatives - exact_mappings: - - evidence:AllEnums slots: - allEnums__entry_name - allEnums__code_1 diff --git a/tests/linkml/test_generators/test_jsonldgen.py b/tests/linkml/test_generators/test_jsonldgen.py index e0eead3a11..923699f36e 100644 --- a/tests/linkml/test_generators/test_jsonldgen.py +++ b/tests/linkml/test_generators/test_jsonldgen.py @@ -6,6 +6,7 @@ from yaml import safe_load from linkml.generators import JSONLDGenerator, RDFGenerator +from linkml.generators.yamlgen import YAMLGenerator from linkml_runtime.utils.schemaview import SchemaView logger = logging.getLogger(__name__) @@ -14,7 +15,7 @@ # network: rdflib fetches the @context URLs in the generated JSON-LD @pytest.mark.network def test_class_uri(input_path): - """`class_uri` should result in an `skos:exactMatch` relationship + """`class_uri` should be kept and also result in an `skos:exactMatch` relationship in the generated JSON-LD and RDF.""" # focusing on the class `Thing` of the schema jsonld_context_class_uri_prefix.yaml: # [...] @@ -42,6 +43,7 @@ def test_class_uri(input_path): # "name": "Thing", # "definition_uri": "http://uri.interlex.org/tgbugs/uris/readable/sparc/Thing", # "from_schema": "https://sparc.olympiangods.org/sparcur/schemas/1/sparc", + # "class_uri": "http://www.w3.org/2000/01/rdf-schema#Resource", # "exact_mappings": [ # "rdfs:Resource" # ], @@ -56,7 +58,7 @@ def test_class_uri(input_path): # check each of the schema classes (according the SchemaView) for class_name, class_info in classes_with_custom_uri.items(): assert class_info["uri"] in classes_jsonld.keys() - assert "class_uri" not in classes_jsonld[class_info["uri"]].keys() + assert classes_jsonld[class_info["uri"]]["class_uri"] == schema_view.expand_curie(class_info["class_uri"]) assert "exact_mappings" in classes_jsonld[class_info["uri"]].keys() assert class_info["class_uri"] in classes_jsonld[class_info["uri"]]["exact_mappings"] @@ -65,6 +67,7 @@ def test_class_uri(input_path): # a linkml:ClassDefinition ; # skos:inScheme ; # skos:exactMatch rdfs:Resource ; + # linkml:class_uri rdfs:Resource ; # linkml:definition_uri ; # linkml:slot_usage [ ] . # [...] @@ -89,10 +92,52 @@ def test_class_uri(input_path): single_item = class_properties[str(p)] class_properties[str(p)] = [single_item] class_properties[str(p)].append(str(o)) - assert "https://w3id.org/linkml/class_uri" not in class_properties.keys() + assert class_properties["https://w3id.org/linkml/class_uri"] == schema_view.expand_curie( + class_info["class_uri"] + ) assert "http://www.w3.org/2004/02/skos/core#exactMatch" in class_properties.keys() assert schema_view.expand_curie(class_info["class_uri"]) == schema_view.expand_curie( class_properties["http://www.w3.org/2004/02/skos/core#exactMatch"] ) or schema_view.expand_curie(class_info["class_uri"]) in schema_view.expand_curie( class_properties["http://www.w3.org/2004/02/skos/core#exactMatch"] ) + + +CLASS_URI_SCHEMA = """ +id: https://example.org/class-uri-test +name: class-uri-test +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/class-uri-test/ + owl: http://www.w3.org/2002/07/owl# +imports: + - linkml:types +default_prefix: ex +default_range: string +classes: + Mapping: + class_uri: owl:Axiom + MappingSet: {} +""" + + +def test_class_uri_kept_alongside_exact_mapping(tmp_path): + """An explicit `class_uri` is kept and added to `exact_mappings`; an implicit one adds no mapping. + + Regression test for https://github.com/linkml/linkml/issues/4066: the JSON-LD + schema dropped `class_uri`, so it no longer round-tripped, and every class + without an explicit `class_uri` gained an `exact_mappings` entry to itself. + """ + schema_path = tmp_path / "class_uri_test.yaml" + schema_path.write_text(CLASS_URI_SCHEMA) + + jsonld = json.loads(JSONLDGenerator(str(schema_path), format="jsonld").serialize()) + classes = {cls["name"]: cls for cls in jsonld["classes"]} + assert classes["Mapping"]["class_uri"] == "http://www.w3.org/2002/07/owl#Axiom" + assert classes["Mapping"]["exact_mappings"] == ["owl:Axiom"] + assert classes["MappingSet"]["class_uri"] == "https://example.org/class-uri-test/MappingSet" + assert "exact_mappings" not in classes["MappingSet"] + + merged = safe_load(YAMLGenerator(str(schema_path)).serialize()) + assert merged["classes"]["Mapping"]["exact_mappings"] == ["owl:Axiom"] + assert "exact_mappings" not in merged["classes"]["MappingSet"] diff --git a/tests/linkml/test_issues/__snapshots__/issue_163d.ttl b/tests/linkml/test_issues/__snapshots__/issue_163d.ttl index f1d4f8996d..c97ee5dfd9 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_163d.ttl +++ b/tests/linkml/test_issues/__snapshots__/issue_163d.ttl @@ -6,8 +6,9 @@ @prefix linkml: . @prefix ex: . ex:C1 a linkml:ClassDefinition ; - skos:exactMatch "ex:C1"^^xsd:anyURI , "ex:mapping1"^^xsd:anyURI ; + skos:exactMatch "ex:mapping1"^^xsd:anyURI ; skos:inScheme "http://example.org/sample/example163d"^^xsd:anyURI ; + linkml:class_uri "http://example.org/C1"^^xsd:anyURI ; linkml:definition_uri "http://example.org/C1"^^xsd:anyURI ; linkml:slot_usage _:c14n1 . ex:boolean a linkml:TypeDefinition ; diff --git a/tests/linkml/test_issues/__snapshots__/issue_167.yaml b/tests/linkml/test_issues/__snapshots__/issue_167.yaml index 613aa7ba5f..595baadb55 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_167.yaml +++ b/tests/linkml/test_issues/__snapshots__/issue_167.yaml @@ -79,8 +79,6 @@ classes: tag: EX:foo3 value: bar3 from_schema: http://example.org/tests/issue167 - exact_mappings: - - http://example.org/tests/issue167/C1 slots: - sa - sb @@ -90,8 +88,6 @@ classes: name: c2 definition_uri: http://example.org/tests/issue167/C2 from_schema: http://example.org/tests/issue167 - exact_mappings: - - http://example.org/tests/issue167/C2 slots: - sb class_uri: http://example.org/tests/issue167/C2 diff --git a/tests/linkml/test_issues/__snapshots__/issue_167b.yaml b/tests/linkml/test_issues/__snapshots__/issue_167b.yaml index 5cc5cee876..cfe6a774ba 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_167b.yaml +++ b/tests/linkml/test_issues/__snapshots__/issue_167b.yaml @@ -370,8 +370,6 @@ classes: description: Annotations as tag value pairs. Note that altLabel is defined in the default namespace, not in the SKOS namespace from_schema: http://example.org/tests/issue167b - exact_mappings: - - ex:MyClass class_uri: ex:MyClass my class 2: name: my class 2 @@ -388,8 +386,6 @@ classes: to be annotated. Note, however, that the annotation source is NOT a CURIE, rather just a string. from_schema: http://example.org/tests/issue167b - exact_mappings: - - ex:MyClass2 class_uri: ex:MyClass2 annotatable: name: annotatable @@ -397,8 +393,6 @@ classes: description: mixin for classes that support annotations from_schema: https://w3id.org/linkml/annotations imported_from: linkml:annotations - exact_mappings: - - linkml:Annotatable mixin: true slots: - annotations @@ -409,8 +403,6 @@ classes: description: a tag/value pair with the semantics of OWL Annotation from_schema: https://w3id.org/linkml/annotations imported_from: linkml:annotations - exact_mappings: - - linkml:Annotation is_a: extension mixins: - annotatable @@ -434,8 +426,6 @@ classes: description: a tag/value pair used to add non-model information to an entry from_schema: https://w3id.org/linkml/extensions imported_from: linkml:extensions - exact_mappings: - - linkml:Extension slots: - extension_tag - extension_value @@ -447,8 +437,6 @@ classes: description: mixin for classes that support extension from_schema: https://w3id.org/linkml/extensions imported_from: linkml:extensions - exact_mappings: - - linkml:Extensible mixin: true slots: - extensions diff --git a/tests/linkml/test_issues/__snapshots__/issue_177_error.yaml b/tests/linkml/test_issues/__snapshots__/issue_177_error.yaml index f691dad688..ee96698d2b 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_177_error.yaml +++ b/tests/linkml/test_issues/__snapshots__/issue_177_error.yaml @@ -14,24 +14,18 @@ classes: name: c1 definition_uri: http://example.org/tests/issue177/C1 from_schema: http://example.org/tests/issue177 - exact_mappings: - - http://example.org/tests/issue177/C1 class_uri: http://example.org/tests/issue177/C1 tree_root: true c2: name: c2 definition_uri: http://example.org/tests/issue177/C2 from_schema: http://example.org/tests/issue177 - exact_mappings: - - http://example.org/tests/issue177/C2 class_uri: http://example.org/tests/issue177/C2 tree_root: true c3: name: c3 definition_uri: http://example.org/tests/issue177/C3 from_schema: http://example.org/tests/issue177 - exact_mappings: - - http://example.org/tests/issue177/C3 class_uri: http://example.org/tests/issue177/C3 tree_root: true metamodel_version: 1.12.0 diff --git a/tests/linkml/test_issues/__snapshots__/issue_18.yaml b/tests/linkml/test_issues/__snapshots__/issue_18.yaml index de9ac80904..7eacf7a19b 100644 --- a/tests/linkml/test_issues/__snapshots__/issue_18.yaml +++ b/tests/linkml/test_issues/__snapshots__/issue_18.yaml @@ -82,8 +82,6 @@ classes: name: c1 definition_uri: http://example.org/tests/issue18/C1 from_schema: http://example.org/tests/issue18 - exact_mappings: - - http://example.org/tests/issue18/C1 slots: - c1s1 - c1s2 @@ -92,8 +90,6 @@ classes: name: c2 definition_uri: http://example.org/tests/issue18/C2 from_schema: http://example.org/tests/issue18 - exact_mappings: - - http://example.org/tests/issue18/C2 slots: - c2s1 - c2s1 @@ -102,8 +98,6 @@ classes: name: c3 definition_uri: http://example.org/tests/issue18/C3 from_schema: http://example.org/tests/issue18 - exact_mappings: - - http://example.org/tests/issue18/C3 slots: - c3s1 class_uri: http://example.org/tests/issue18/C3 @@ -111,8 +105,6 @@ classes: name: c4 definition_uri: http://example.org/tests/issue18/C4 from_schema: http://example.org/tests/issue18 - exact_mappings: - - http://example.org/tests/issue18/C4 class_uri: http://example.org/tests/issue18/C4 metamodel_version: 1.12.0 source_file: issue_18.yaml diff --git a/tests/linkml/test_issues/__snapshots__/linkml_issue_384.other.txt b/tests/linkml/test_issues/__snapshots__/linkml_issue_384.other.txt index 86a728f38c..1755c9215a 100644 --- a/tests/linkml/test_issues/__snapshots__/linkml_issue_384.other.txt +++ b/tests/linkml/test_issues/__snapshots__/linkml_issue_384.other.txt @@ -443,8 +443,6 @@ classes: name: Thing definition_uri: https://w3id.org/linkml/examples/personinfo/Thing from_schema: https://w3id.org/linkml/examples/personinfo - exact_mappings: - - ex:Thing slots: - id - full_name @@ -482,8 +480,6 @@ classes: name: Organization definition_uri: https://w3id.org/linkml/examples/personinfo/Organization from_schema: https://w3id.org/linkml/examples/personinfo - exact_mappings: - - ex:Organization is_a: Thing slots: - id @@ -501,8 +497,6 @@ classes: name: GeoObject definition_uri: https://w3id.org/linkml/examples/personinfo/GeoObject from_schema: https://w3id.org/linkml/examples/personinfo - exact_mappings: - - ex:GeoObject is_a: Thing slots: - id @@ -527,8 +521,6 @@ classes: name: GeoAge definition_uri: https://w3id.org/linkml/examples/personinfo/GeoAge from_schema: https://w3id.org/linkml/examples/personinfo - exact_mappings: - - ex:GeoAge slots: - geoAge__unit - geoAge__value @@ -600,19 +592,19 @@ generation_date: '2000-01-01T00:00:00' # --- linkml_issue_384.ttl --- . - "ex:GeoAge"^^ . "https://w3id.org/linkml/examples/personinfo"^^ . . . + "https://w3id.org/linkml/examples/personinfo/GeoAge"^^ . "https://w3id.org/linkml/examples/personinfo/GeoAge"^^ . _:c14n2 . . . . - "ex:GeoObject"^^ . "https://w3id.org/linkml/examples/personinfo"^^ . . . + "https://w3id.org/linkml/examples/personinfo/GeoObject"^^ . "https://w3id.org/linkml/examples/personinfo/GeoObject"^^ . . _:c14n0 . @@ -621,8 +613,8 @@ generation_date: '2000-01-01T00:00:00' . . . - "ex:Organization"^^ . "https://w3id.org/linkml/examples/personinfo"^^ . + "https://w3id.org/linkml/examples/personinfo/Organization"^^ . "https://w3id.org/linkml/examples/personinfo/Organization"^^ . . _:c14n6 . @@ -664,6 +656,7 @@ generation_date: '2000-01-01T00:00:00' . . . + "http://schema.org/Person"^^ . "https://w3id.org/linkml/examples/personinfo/Person"^^ . . _:c14n1 . @@ -689,8 +682,8 @@ generation_date: '2000-01-01T00:00:00' "https://w3id.org/linkml/examples/personinfo/parent"^^ . "parent" . . - "ex:Thing"^^ . "https://w3id.org/linkml/examples/personinfo"^^ . + "https://w3id.org/linkml/examples/personinfo/Thing"^^ . "https://w3id.org/linkml/examples/personinfo/Thing"^^ . _:c14n4 . . diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.json b/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.json index f081ec8dfb..2db77bbf7a 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.json +++ b/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.json @@ -1532,9 +1532,6 @@ "name": "AnyOfSimpleType", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfSimpleType", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfSimpleType" - ], "slots": [ "anyOfSimpleType__attribute1" ], @@ -1551,15 +1548,13 @@ } ] } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfSimpleType" }, { "name": "AnyOfClasses", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfClasses", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfClasses" - ], "slots": [ "anyOfClasses__attribute2" ], @@ -1576,15 +1571,13 @@ } ] } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfClasses" }, { "name": "AnyOfEnums", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfEnums", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfEnums" - ], "slots": [ "anyOfEnums__attribute3" ], @@ -1601,15 +1594,13 @@ } ] } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfEnums" }, { "name": "AnyOfMix", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfMix", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfMix" - ], "slots": [ "anyOfMix__attribute4" ], @@ -1629,15 +1620,13 @@ } ] } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfMix" }, { "name": "EqualsString", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsString", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:EqualsString" - ], "slots": [ "equalsString__attribute5" ], @@ -1647,15 +1636,13 @@ "name": "attribute5", "equals_string": "foo" } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsString" }, { "name": "EqualsStringIn", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsStringIn", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:EqualsStringIn" - ], "slots": [ "equalsStringIn__attribute6" ], @@ -1668,15 +1655,13 @@ "bar" ] } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsStringIn" }, { "name": "HasAliases", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/HasAliases", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:HasAliases" - ], "mixin": true, "slots": [ "hasAliases__aliases" @@ -1688,20 +1673,19 @@ "slot_uri": "skos:altLabel", "multivalued": true } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/HasAliases" }, { "name": "Friend", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Friend", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Friend" - ], "abstract": true, "slots": [ "name" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Friend" }, { "name": "Person", @@ -1737,7 +1721,6 @@ "schema:Person" ], "exact_mappings": [ - "ks:Person", "schema:Person" ], "rank": 2, @@ -1764,7 +1747,8 @@ "name": "is_living", "range": "LifeStatusEnum" } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Person" }, { "name": "Organization", @@ -1774,9 +1758,6 @@ "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Organization", "description": "An organization.\n\nThis description\nincludes newlines\n\n## Markdown headers\n\n * and\n * a\n * list", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Organization" - ], "rank": 3, "mixins": [ "HasAliases" @@ -1786,15 +1767,13 @@ "name", "hasAliases__aliases" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Organization" }, { "name": "Place", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Place", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Place" - ], "mixins": [ "HasAliases" ], @@ -1803,21 +1782,20 @@ "name", "hasAliases__aliases" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Place" }, { "name": "Address", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Address", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Address" - ], "slots": [ "street", "city", "altitude" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Address" }, { "name": "Concept", @@ -1826,23 +1804,18 @@ ], "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Concept", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Concept" - ], "slots": [ "id", "name", "in_code_system" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Concept" }, { "name": "DiagnosisConcept", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/DiagnosisConcept", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:DiagnosisConcept" - ], "close_mappings": [ "biolink:Disease" ], @@ -1852,45 +1825,39 @@ "name", "in_code_system" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/DiagnosisConcept" }, { "name": "ProcedureConcept", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/ProcedureConcept", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:ProcedureConcept" - ], "is_a": "Concept", "slots": [ "id", "name", "in_code_system" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/ProcedureConcept" }, { "name": "Event", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Event", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Event" - ], "slots": [ "started_at_time", "ended_at_time", "is_current", "metadata" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Event" }, { "name": "Relationship", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Relationship", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Relationship" - ], "slots": [ "started_at_time", "ended_at_time", @@ -1898,15 +1865,13 @@ "type", "Relationship_cordialness" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Relationship" }, { "name": "FamilialRelationship", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/FamilialRelationship", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:FamilialRelationship" - ], "rank": 5, "is_a": "Relationship", "slots": [ @@ -1916,15 +1881,13 @@ "FamilialRelationship_type", "FamilialRelationship_related_to" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/FamilialRelationship" }, { "name": "BirthEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/BirthEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:BirthEvent" - ], "is_a": "Event", "slots": [ "started_at_time", @@ -1933,15 +1896,13 @@ "metadata", "in_location" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/BirthEvent" }, { "name": "EmploymentEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/EmploymentEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:EmploymentEvent" - ], "rank": 6, "is_a": "Event", "slots": [ @@ -1952,15 +1913,13 @@ "employed_at", "EmploymentEvent_type" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/EmploymentEvent" }, { "name": "MedicalEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/MedicalEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:MedicalEvent" - ], "is_a": "Event", "slots": [ "started_at_time", @@ -1971,28 +1930,24 @@ "diagnosis", "procedure" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/MedicalEvent" }, { "name": "WithLocation", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/WithLocation", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:WithLocation" - ], "mixin": true, "slots": [ "in_location" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/WithLocation" }, { "name": "MarriageEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/MarriageEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:MarriageEvent" - ], "is_a": "Event", "mixins": [ "WithLocation" @@ -2005,15 +1960,13 @@ "married_to", "in_location" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/MarriageEvent" }, { "name": "Company", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Company", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Company" - ], "is_a": "Organization", "slots": [ "id", @@ -2028,29 +1981,25 @@ "slot_uri": "schema:ceo", "range": "Person" } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Company" }, { "name": "CodeSystem", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/CodeSystem", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:CodeSystem" - ], "slots": [ "id", "name", "long_id" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/CodeSystem" }, { "name": "Dataset", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Dataset", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Dataset" - ], "rank": 1, "slots": [ "metadata", @@ -2089,6 +2038,7 @@ "inlined_as_list": true } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Dataset", "tree_root": true }, { @@ -2096,9 +2046,6 @@ "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/FakeClass", "deprecated": "this is not a real class, we are using it to test deprecation", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:FakeClass" - ], "slots": [ "fakeClass__test_attribute" ], @@ -2107,15 +2054,13 @@ { "name": "test_attribute" } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/FakeClass" }, { "name": "ClassWithSpaces", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/ClassWithSpaces", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:ClassWithSpaces" - ], "slots": [ "classWithSpaces__slot_with_space_1" ], @@ -2124,15 +2069,13 @@ { "name": "slot_with_space_1" } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/ClassWithSpaces" }, { "name": "SubclassTest", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubclassTest", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:SubclassTest" - ], "is_a": "ClassWithSpaces", "slots": [ "classWithSpaces__slot_with_space_1", @@ -2144,36 +2087,33 @@ "name": "slot_with_space_2", "range": "ClassWithSpaces" } - ] + ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubclassTest" }, { "name": "SubSubClass2", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubSubClass2", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:SubSubClass2" - ], "is_a": "SubclassTest", "slots": [ "classWithSpaces__slot_with_space_1", "subclassTest__slot_with_space_2" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubSubClass2" }, { "name": "TubSubClass1", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/TubSubClass1", "description": "Same depth as Sub sub class 1", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:TubSubClass1" - ], "is_a": "SubclassTest", "slots": [ "classWithSpaces__slot_with_space_1", "subclassTest__slot_with_space_2" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/TubSubClass1" }, { "name": "AnyObject", @@ -2183,7 +2123,8 @@ "exact_mappings": [ "linkml:Any" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any" }, { "name": "Activity", @@ -2193,9 +2134,6 @@ "mappings": [ "prov:Activity" ], - "exact_mappings": [ - "core:Activity" - ], "slots": [ "id", "started_at_time", @@ -2205,7 +2143,8 @@ "used", "description" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/core/Activity" }, { "name": "Agent", @@ -2220,7 +2159,8 @@ "acted_on_behalf_of", "was_informed_by" ], - "slot_usage": {} + "slot_usage": {}, + "class_uri": "http://www.w3.org/ns/prov#Agent" } ], "metamodel_version": "1.12.0", diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.jsonld b/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.jsonld index dd2c8b077f..f4ea08ea9b 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/genjsonld/meta.jsonld @@ -1635,9 +1635,6 @@ "name": "AnyOfSimpleType", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfSimpleType", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfSimpleType" - ], "slots": [ "anyOfSimpleType__attribute1" ], @@ -1658,15 +1655,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfSimpleType", "@type": "ClassDefinition" }, { "name": "AnyOfClasses", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfClasses", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfClasses" - ], "slots": [ "anyOfClasses__attribute2" ], @@ -1687,15 +1682,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfClasses", "@type": "ClassDefinition" }, { "name": "AnyOfEnums", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfEnums", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfEnums" - ], "slots": [ "anyOfEnums__attribute3" ], @@ -1716,15 +1709,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfEnums", "@type": "ClassDefinition" }, { "name": "AnyOfMix", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfMix", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:AnyOfMix" - ], "slots": [ "anyOfMix__attribute4" ], @@ -1749,15 +1740,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/AnyOfMix", "@type": "ClassDefinition" }, { "name": "EqualsString", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsString", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:EqualsString" - ], "slots": [ "equalsString__attribute5" ], @@ -1769,15 +1758,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsString", "@type": "ClassDefinition" }, { "name": "EqualsStringIn", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsStringIn", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:EqualsStringIn" - ], "slots": [ "equalsStringIn__attribute6" ], @@ -1792,15 +1779,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/EqualsStringIn", "@type": "ClassDefinition" }, { "name": "HasAliases", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/HasAliases", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:HasAliases" - ], "mixin": true, "slots": [ "hasAliases__aliases" @@ -1814,20 +1799,19 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/HasAliases", "@type": "ClassDefinition" }, { "name": "Friend", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Friend", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Friend" - ], "abstract": true, "slots": [ "name" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Friend", "@type": "ClassDefinition" }, { @@ -1868,7 +1852,6 @@ "schema:Person" ], "exact_mappings": [ - "ks:Person", "schema:Person" ], "rank": 2, @@ -1897,6 +1880,7 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Person", "@type": "ClassDefinition" }, { @@ -1907,9 +1891,6 @@ "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Organization", "description": "An organization.\n\nThis description\nincludes newlines\n\n## Markdown headers\n\n * and\n * a\n * list", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Organization" - ], "rank": 3, "mixins": [ "HasAliases" @@ -1920,15 +1901,13 @@ "hasAliases__aliases" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Organization", "@type": "ClassDefinition" }, { "name": "Place", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Place", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Place" - ], "mixins": [ "HasAliases" ], @@ -1938,21 +1917,20 @@ "hasAliases__aliases" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Place", "@type": "ClassDefinition" }, { "name": "Address", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Address", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Address" - ], "slots": [ "street", "city", "altitude" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Address", "@type": "ClassDefinition" }, { @@ -1962,24 +1940,19 @@ ], "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Concept", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Concept" - ], "slots": [ "id", "name", "in_code_system" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Concept", "@type": "ClassDefinition" }, { "name": "DiagnosisConcept", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/DiagnosisConcept", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:DiagnosisConcept" - ], "close_mappings": [ "biolink:Disease" ], @@ -1990,15 +1963,13 @@ "in_code_system" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/DiagnosisConcept", "@type": "ClassDefinition" }, { "name": "ProcedureConcept", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/ProcedureConcept", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:ProcedureConcept" - ], "is_a": "Concept", "slots": [ "id", @@ -2006,15 +1977,13 @@ "in_code_system" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/ProcedureConcept", "@type": "ClassDefinition" }, { "name": "Event", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Event", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Event" - ], "slots": [ "started_at_time", "ended_at_time", @@ -2022,15 +1991,13 @@ "metadata" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Event", "@type": "ClassDefinition" }, { "name": "Relationship", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Relationship", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Relationship" - ], "slots": [ "started_at_time", "ended_at_time", @@ -2039,15 +2006,13 @@ "Relationship_cordialness" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Relationship", "@type": "ClassDefinition" }, { "name": "FamilialRelationship", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/FamilialRelationship", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:FamilialRelationship" - ], "rank": 5, "is_a": "Relationship", "slots": [ @@ -2058,15 +2023,13 @@ "FamilialRelationship_related_to" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/FamilialRelationship", "@type": "ClassDefinition" }, { "name": "BirthEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/BirthEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:BirthEvent" - ], "is_a": "Event", "slots": [ "started_at_time", @@ -2076,15 +2039,13 @@ "in_location" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/BirthEvent", "@type": "ClassDefinition" }, { "name": "EmploymentEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/EmploymentEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:EmploymentEvent" - ], "rank": 6, "is_a": "Event", "slots": [ @@ -2096,15 +2057,13 @@ "EmploymentEvent_type" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/EmploymentEvent", "@type": "ClassDefinition" }, { "name": "MedicalEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/MedicalEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:MedicalEvent" - ], "is_a": "Event", "slots": [ "started_at_time", @@ -2116,29 +2075,25 @@ "procedure" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/MedicalEvent", "@type": "ClassDefinition" }, { "name": "WithLocation", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/WithLocation", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:WithLocation" - ], "mixin": true, "slots": [ "in_location" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/WithLocation", "@type": "ClassDefinition" }, { "name": "MarriageEvent", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/MarriageEvent", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:MarriageEvent" - ], "is_a": "Event", "mixins": [ "WithLocation" @@ -2152,15 +2107,13 @@ "in_location" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/MarriageEvent", "@type": "ClassDefinition" }, { "name": "Company", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Company", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Company" - ], "is_a": "Organization", "slots": [ "id", @@ -2177,30 +2130,26 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Company", "@type": "ClassDefinition" }, { "name": "CodeSystem", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/CodeSystem", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:CodeSystem" - ], "slots": [ "id", "name", "long_id" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/CodeSystem", "@type": "ClassDefinition" }, { "name": "Dataset", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/Dataset", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:Dataset" - ], "rank": 1, "slots": [ "metadata", @@ -2243,6 +2192,7 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/Dataset", "tree_root": true, "@type": "ClassDefinition" }, @@ -2251,9 +2201,6 @@ "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/FakeClass", "deprecated": "this is not a real class, we are using it to test deprecation", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:FakeClass" - ], "slots": [ "fakeClass__test_attribute" ], @@ -2264,15 +2211,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/FakeClass", "@type": "ClassDefinition" }, { "name": "ClassWithSpaces", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/ClassWithSpaces", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:ClassWithSpaces" - ], "slots": [ "classWithSpaces__slot_with_space_1" ], @@ -2283,15 +2228,13 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/ClassWithSpaces", "@type": "ClassDefinition" }, { "name": "SubclassTest", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubclassTest", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:SubclassTest" - ], "is_a": "ClassWithSpaces", "slots": [ "classWithSpaces__slot_with_space_1", @@ -2305,21 +2248,20 @@ "@type": "SlotDefinition" } ], + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubclassTest", "@type": "ClassDefinition" }, { "name": "SubSubClass2", "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubSubClass2", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:SubSubClass2" - ], "is_a": "SubclassTest", "slots": [ "classWithSpaces__slot_with_space_1", "subclassTest__slot_with_space_2" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/SubSubClass2", "@type": "ClassDefinition" }, { @@ -2327,15 +2269,13 @@ "definition_uri": "https://w3id.org/linkml/tests/kitchen_sink/TubSubClass1", "description": "Same depth as Sub sub class 1", "from_schema": "https://w3id.org/linkml/tests/kitchen_sink", - "exact_mappings": [ - "ks:TubSubClass1" - ], "is_a": "SubclassTest", "slots": [ "classWithSpaces__slot_with_space_1", "subclassTest__slot_with_space_2" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/kitchen_sink/TubSubClass1", "@type": "ClassDefinition" }, { @@ -2347,6 +2287,7 @@ "linkml:Any" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any", "@type": "ClassDefinition" }, { @@ -2357,9 +2298,6 @@ "mappings": [ "prov:Activity" ], - "exact_mappings": [ - "core:Activity" - ], "slots": [ "id", "started_at_time", @@ -2370,6 +2308,7 @@ "description" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/tests/core/Activity", "@type": "ClassDefinition" }, { @@ -2386,6 +2325,7 @@ "was_informed_by" ], "slot_usage": {}, + "class_uri": "http://www.w3.org/ns/prov#Agent", "@type": "ClassDefinition" } ], diff --git a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld index 3f4b89cb3a..55b28aa396 100644 --- a/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld +++ b/tests/linkml/test_scripts/__snapshots__/genjsonld/simple_uri_test.jsonld @@ -438,6 +438,7 @@ "state" ], "slot_usage": {}, + "class_uri": "http://samples.r.us/foo#model", "@type": "ClassDefinition" }, { @@ -449,6 +450,7 @@ "linkml:Any" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Any", "@type": "ClassDefinition" }, { @@ -457,15 +459,13 @@ "description": "a tag/value pair used to add non-model information to an entry", "from_schema": "https://w3id.org/linkml/extensions", "imported_from": "linkml:extensions", - "exact_mappings": [ - "linkml:Extension" - ], "slots": [ "extension_tag", "extension_value", "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extension", "@type": "ClassDefinition" }, { @@ -474,14 +474,12 @@ "description": "mixin for classes that support extension", "from_schema": "https://w3id.org/linkml/extensions", "imported_from": "linkml:extensions", - "exact_mappings": [ - "linkml:Extensible" - ], "mixin": true, "slots": [ "extensions" ], "slot_usage": {}, + "class_uri": "https://w3id.org/linkml/Extensible", "@type": "ClassDefinition" } ], diff --git a/tests/linkml/test_scripts/__snapshots__/genyaml/clean.yaml b/tests/linkml/test_scripts/__snapshots__/genyaml/clean.yaml index f665ed9b28..91df023ec9 100644 --- a/tests/linkml/test_scripts/__snapshots__/genyaml/clean.yaml +++ b/tests/linkml/test_scripts/__snapshots__/genyaml/clean.yaml @@ -316,8 +316,6 @@ classes: name: geographic location definition_uri: http://example.org/tests/cleanyaml/GeographicLocation from_schema: http://example.org/tests/cleanyaml - exact_mappings: - - http://example.org/tests/cleanyaml/GeographicLocation slots: - k class_uri: http://example.org/tests/cleanyaml/GeographicLocation @@ -325,8 +323,6 @@ classes: name: geographic location at time definition_uri: http://example.org/tests/cleanyaml/GeographicLocationAtTime from_schema: http://example.org/tests/cleanyaml - exact_mappings: - - http://example.org/tests/cleanyaml/GeographicLocationAtTime is_a: geographic location slots: - k diff --git a/tests/linkml/test_scripts/__snapshots__/genyaml/meta.yaml b/tests/linkml/test_scripts/__snapshots__/genyaml/meta.yaml index 014151bd4d..2519607ad8 100644 --- a/tests/linkml/test_scripts/__snapshots__/genyaml/meta.yaml +++ b/tests/linkml/test_scripts/__snapshots__/genyaml/meta.yaml @@ -1265,8 +1265,6 @@ classes: name: AnyOfSimpleType definition_uri: https://w3id.org/linkml/tests/kitchen_sink/AnyOfSimpleType from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:AnyOfSimpleType slots: - anyOfSimpleType__attribute1 attributes: @@ -1280,8 +1278,6 @@ classes: name: AnyOfClasses definition_uri: https://w3id.org/linkml/tests/kitchen_sink/AnyOfClasses from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:AnyOfClasses slots: - anyOfClasses__attribute2 attributes: @@ -1295,8 +1291,6 @@ classes: name: AnyOfEnums definition_uri: https://w3id.org/linkml/tests/kitchen_sink/AnyOfEnums from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:AnyOfEnums slots: - anyOfEnums__attribute3 attributes: @@ -1310,8 +1304,6 @@ classes: name: AnyOfMix definition_uri: https://w3id.org/linkml/tests/kitchen_sink/AnyOfMix from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:AnyOfMix slots: - anyOfMix__attribute4 attributes: @@ -1326,8 +1318,6 @@ classes: name: EqualsString definition_uri: https://w3id.org/linkml/tests/kitchen_sink/EqualsString from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:EqualsString slots: - equalsString__attribute5 attributes: @@ -1339,8 +1329,6 @@ classes: name: EqualsStringIn definition_uri: https://w3id.org/linkml/tests/kitchen_sink/EqualsStringIn from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:EqualsStringIn slots: - equalsStringIn__attribute6 attributes: @@ -1354,8 +1342,6 @@ classes: name: HasAliases definition_uri: https://w3id.org/linkml/tests/kitchen_sink/HasAliases from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:HasAliases mixin: true slots: - hasAliases__aliases @@ -1369,8 +1355,6 @@ classes: name: Friend definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Friend from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Friend abstract: true slots: - name @@ -1401,7 +1385,6 @@ classes: - https://en.wikipedia.org/wiki/Person - schema:Person exact_mappings: - - ks:Person - schema:Person rank: 2 mixins: @@ -1442,8 +1425,6 @@ classes: description: "An organization.\n\nThis description\nincludes newlines\n\n## Markdown\ \ headers\n\n * and\n * a\n * list" from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Organization rank: 3 mixins: - HasAliases @@ -1456,8 +1437,6 @@ classes: name: Place definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Place from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Place mixins: - HasAliases slots: @@ -1469,8 +1448,6 @@ classes: name: Address definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Address from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Address slots: - street - city @@ -1482,8 +1459,6 @@ classes: - CODE definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Concept from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Concept slots: - id - name @@ -1493,8 +1468,6 @@ classes: name: DiagnosisConcept definition_uri: https://w3id.org/linkml/tests/kitchen_sink/DiagnosisConcept from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:DiagnosisConcept close_mappings: - biolink:Disease is_a: Concept @@ -1507,8 +1480,6 @@ classes: name: ProcedureConcept definition_uri: https://w3id.org/linkml/tests/kitchen_sink/ProcedureConcept from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:ProcedureConcept is_a: Concept slots: - id @@ -1519,8 +1490,6 @@ classes: name: Event definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Event from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Event slots: - started at time - ended at time @@ -1531,8 +1500,6 @@ classes: name: Relationship definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Relationship from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Relationship slots: - started at time - ended at time @@ -1548,8 +1515,6 @@ classes: name: FamilialRelationship definition_uri: https://w3id.org/linkml/tests/kitchen_sink/FamilialRelationship from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:FamilialRelationship rank: 5 is_a: Relationship slots: @@ -1574,8 +1539,6 @@ classes: name: BirthEvent definition_uri: https://w3id.org/linkml/tests/kitchen_sink/BirthEvent from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:BirthEvent is_a: Event slots: - started at time @@ -1588,8 +1551,6 @@ classes: name: EmploymentEvent definition_uri: https://w3id.org/linkml/tests/kitchen_sink/EmploymentEvent from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:EmploymentEvent rank: 6 is_a: Event slots: @@ -1611,8 +1572,6 @@ classes: name: MedicalEvent definition_uri: https://w3id.org/linkml/tests/kitchen_sink/MedicalEvent from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:MedicalEvent is_a: Event slots: - started at time @@ -1627,8 +1586,6 @@ classes: name: WithLocation definition_uri: https://w3id.org/linkml/tests/kitchen_sink/WithLocation from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:WithLocation mixin: true slots: - in location @@ -1637,8 +1594,6 @@ classes: name: MarriageEvent definition_uri: https://w3id.org/linkml/tests/kitchen_sink/MarriageEvent from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:MarriageEvent is_a: Event mixins: - WithLocation @@ -1654,8 +1609,6 @@ classes: name: Company definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Company from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Company is_a: Organization slots: - id @@ -1672,8 +1625,6 @@ classes: name: CodeSystem definition_uri: https://w3id.org/linkml/tests/kitchen_sink/CodeSystem from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:CodeSystem slots: - id - name @@ -1683,8 +1634,6 @@ classes: name: Dataset definition_uri: https://w3id.org/linkml/tests/kitchen_sink/Dataset from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:Dataset rank: 1 slots: - metadata @@ -1723,8 +1672,6 @@ classes: definition_uri: https://w3id.org/linkml/tests/kitchen_sink/FakeClass deprecated: this is not a real class, we are using it to test deprecation from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:FakeClass slots: - fakeClass__test_attribute attributes: @@ -1735,8 +1682,6 @@ classes: name: class with spaces definition_uri: https://w3id.org/linkml/tests/kitchen_sink/ClassWithSpaces from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:ClassWithSpaces slots: - classWithSpaces__slot_with_space_1 attributes: @@ -1747,8 +1692,6 @@ classes: name: subclass test definition_uri: https://w3id.org/linkml/tests/kitchen_sink/SubclassTest from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:SubclassTest is_a: class with spaces slots: - classWithSpaces__slot_with_space_1 @@ -1762,8 +1705,6 @@ classes: name: Sub sub class 2 definition_uri: https://w3id.org/linkml/tests/kitchen_sink/SubSubClass2 from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:SubSubClass2 is_a: subclass test slots: - classWithSpaces__slot_with_space_1 @@ -1774,8 +1715,6 @@ classes: definition_uri: https://w3id.org/linkml/tests/kitchen_sink/TubSubClass1 description: Same depth as Sub sub class 1 from_schema: https://w3id.org/linkml/tests/kitchen_sink - exact_mappings: - - ks:TubSubClass1 is_a: subclass test slots: - classWithSpaces__slot_with_space_1 @@ -1796,8 +1735,6 @@ classes: from_schema: https://w3id.org/linkml/tests/core mappings: - prov:Activity - exact_mappings: - - core:Activity slots: - id - started at time diff --git a/tests/linkml/test_utils/__snapshots__/import_test_1.json b/tests/linkml/test_utils/__snapshots__/import_test_1.json index 9f7b1cb988..0ac73bb3bc 100644 --- a/tests/linkml/test_utils/__snapshots__/import_test_1.json +++ b/tests/linkml/test_utils/__snapshots__/import_test_1.json @@ -401,9 +401,6 @@ "name": "class_1", "definition_uri": "http://example.org/import_test_1#Class1", "from_schema": "http://example.org/import_test_1", - "exact_mappings": [ - "test:Class1" - ], "slots": [ "slot_1" ], @@ -413,9 +410,6 @@ "name": "class_2", "definition_uri": "http://example.org/import_test_2#Class2", "from_schema": "http://example.org/import_test_2", - "exact_mappings": [ - "test2:Class2" - ], "slots": [ "slot_2" ], @@ -425,9 +419,6 @@ "name": "class_3", "definition_uri": "http://example.org/import_test_3#Class3", "from_schema": "http://example.org/import_test_3", - "exact_mappings": [ - "test3:Class3" - ], "slots": [ "slot_3" ], @@ -437,9 +428,6 @@ "name": "class_4", "definition_uri": "http://example.org/import_test_4#Class4", "from_schema": "http://example.org/import_test_4", - "exact_mappings": [ - "test4:Class4" - ], "slots": [ "slot_4" ], diff --git a/tests/linkml/test_utils/__snapshots__/multi_usages.yaml b/tests/linkml/test_utils/__snapshots__/multi_usages.yaml index 3b1000a818..90ca30c93f 100644 --- a/tests/linkml/test_utils/__snapshots__/multi_usages.yaml +++ b/tests/linkml/test_utils/__snapshots__/multi_usages.yaml @@ -347,8 +347,6 @@ classes: name: root_class definition_uri: http://example.org/multi_usages/RootClass from_schema: http://example.org/multi_usages - exact_mappings: - - http://example.org/multi_usages/RootClass slots: - s1 class_uri: http://example.org/multi_usages/RootClass @@ -356,8 +354,6 @@ classes: name: child_class1 definition_uri: http://example.org/multi_usages/ChildClass1 from_schema: http://example.org/multi_usages - exact_mappings: - - http://example.org/multi_usages/ChildClass1 is_a: root_class slots: - child_class1_s1 @@ -372,8 +368,6 @@ classes: name: child_class2 definition_uri: http://example.org/multi_usages/ChildClass2 from_schema: http://example.org/multi_usages - exact_mappings: - - http://example.org/multi_usages/ChildClass2 is_a: child_class1 slots: - child_class2_s1 @@ -388,8 +382,6 @@ classes: name: child_class3 definition_uri: http://example.org/multi_usages/ChildClass3 from_schema: http://example.org/multi_usages - exact_mappings: - - http://example.org/multi_usages/ChildClass3 is_a: child_class2 slots: - child_class3_s1 diff --git a/tests/linkml/test_utils/__snapshots__/multi_usages_2.yaml b/tests/linkml/test_utils/__snapshots__/multi_usages_2.yaml index 00077ce6a3..7d94e068ef 100644 --- a/tests/linkml/test_utils/__snapshots__/multi_usages_2.yaml +++ b/tests/linkml/test_utils/__snapshots__/multi_usages_2.yaml @@ -350,8 +350,6 @@ classes: name: root_class definition_uri: http://example.org/multi_usages_2/RootClass from_schema: http://example.org/multi_usages_2 - exact_mappings: - - http://example.org/multi_usages_2/RootClass slots: - s1 class_uri: http://example.org/multi_usages_2/RootClass @@ -359,8 +357,6 @@ classes: name: child_class1 definition_uri: http://example.org/multi_usages_2/ChildClass1 from_schema: http://example.org/multi_usages_2 - exact_mappings: - - http://example.org/multi_usages_2/ChildClass1 is_a: root_class slots: - child_class1_s1 @@ -375,8 +371,6 @@ classes: name: child_class2 definition_uri: http://example.org/multi_usages_2/ChildClass2 from_schema: http://example.org/multi_usages_2 - exact_mappings: - - http://example.org/multi_usages_2/ChildClass2 is_a: child_class1 slots: - child_class2_s1 @@ -391,8 +385,6 @@ classes: name: child_class3 definition_uri: http://example.org/multi_usages_2/ChildClass3 from_schema: http://example.org/multi_usages_2 - exact_mappings: - - http://example.org/multi_usages_2/ChildClass3 is_a: child_class2 slots: - child_class3_s1 diff --git a/tests/linkml/test_utils/__snapshots__/uriandcurie.json b/tests/linkml/test_utils/__snapshots__/uriandcurie.json index 944ae90e21..2c39326ff5 100644 --- a/tests/linkml/test_utils/__snapshots__/uriandcurie.json +++ b/tests/linkml/test_utils/__snapshots__/uriandcurie.json @@ -160,9 +160,6 @@ "name": "C1", "definition_uri": "http://example.org/test/uriandcurieC1", "from_schema": "http://example.org/test/uriandcurie", - "exact_mappings": [ - "m:C1" - ], "slots": [ "id", "hasCurie", @@ -171,6 +168,7 @@ "id2" ], "slot_usage": {}, + "class_uri": "http://example.org/test/uriandcurieC1", "@type": "ClassDefinition" } ], From 01ea592de8bfb8911b3304b9c070a8019bffda5f Mon Sep 17 00:00:00 2001 From: Corey Cox <69321580+amc-corey-cox@users.noreply.github.com> Date: Wed, 7 Oct 2026 20:27:28 -0500 Subject: [PATCH 04/30] Fix gen-jsonld with a relative schema path --- .../linkml/src/linkml/generators/jsonldgen.py | 11 ++- .../linkml/test_generators/test_jsonldgen.py | 79 +++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/packages/linkml/src/linkml/generators/jsonldgen.py b/packages/linkml/src/linkml/generators/jsonldgen.py index 03a294759a..1de32d7414 100644 --- a/packages/linkml/src/linkml/generators/jsonldgen.py +++ b/packages/linkml/src/linkml/generators/jsonldgen.py @@ -58,6 +58,9 @@ class JSONLDGenerator(Generator): original_schema: SchemaDefinition = None """See https://github.com/linkml/linkml/issues/871""" + original_base_dir: str | None = None + """The caller's base_dir, before SchemaLoader replaces it with one derived from the schema path""" + context: Sequence[str] | None = field(default_factory=list) """Path to a JSONLD context file""" @@ -66,6 +69,7 @@ class JSONLDGenerator(Generator): def __post_init__(self) -> None: self.original_schema = deepcopy(self.schema) + self.original_base_dir = self.base_dir super().__post_init__() def _add_type(self, node: YAMLRoot) -> dict: @@ -186,9 +190,12 @@ def end_schema( context_kwargs["metadata"] = False # Forward importmap/base_dir so the spawned ContextGenerator can # re-resolve any URI-style imports in ``self.original_schema`` - # through the same ``--importmap`` the caller supplied. + # through the same ``--importmap`` the caller supplied. Forward the + # caller's base_dir, not the one SchemaLoader derived from the schema + # path: the latter would resolve a relative path against its own + # directory twice. context_kwargs.setdefault("importmap", self.importmap) - context_kwargs.setdefault("base_dir", self.base_dir) + context_kwargs.setdefault("base_dir", self.original_base_dir) add_prefixes = ContextGenerator(self.original_schema, **context_kwargs).serialize() add_prefixes_json = loads(add_prefixes) metamodel_ctx = self.metamodel_context or METAMODEL_CONTEXT_URI diff --git a/tests/linkml/test_generators/test_jsonldgen.py b/tests/linkml/test_generators/test_jsonldgen.py index 923699f36e..dcf3210978 100644 --- a/tests/linkml/test_generators/test_jsonldgen.py +++ b/tests/linkml/test_generators/test_jsonldgen.py @@ -2,10 +2,12 @@ import logging import pytest +from click.testing import CliRunner from rdflib import Graph, term from yaml import safe_load from linkml.generators import JSONLDGenerator, RDFGenerator +from linkml.generators.jsonldgen import cli as jsonld_cli from linkml.generators.yamlgen import YAMLGenerator from linkml_runtime.utils.schemaview import SchemaView @@ -141,3 +143,80 @@ def test_class_uri_kept_alongside_exact_mapping(tmp_path): merged = safe_load(YAMLGenerator(str(schema_path)).serialize()) assert merged["classes"]["Mapping"]["exact_mappings"] == ["owl:Axiom"] assert "exact_mappings" not in merged["classes"]["MappingSet"] + + +CORE_SCHEMA = """ +id: https://example.org/core +name: core +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/tiny/ +imports: + - linkml:types +default_prefix: ex +default_range: string +classes: + Base: + attributes: + label: {} +""" + +TINY_SCHEMA = """ +id: https://example.org/tiny +name: tiny +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/tiny/ +imports: + - linkml:types + - ./core +default_prefix: ex +default_range: string +classes: + Thing: + is_a: Base + attributes: + name: {} +""" + + +@pytest.mark.parametrize( + "make_output", + [ + pytest.param( + lambda schema_dir: CliRunner().invoke(jsonld_cli, ["--no-metadata", "dist/tiny.yaml"]).output, + id="cli-relative-path", + ), + pytest.param( + lambda schema_dir: JSONLDGenerator("dist/tiny.yaml", metadata=False).serialize(), + id="api-relative-path", + ), + pytest.param( + lambda schema_dir: JSONLDGenerator("tiny.yaml", base_dir="dist", metadata=False).serialize(), + id="api-relative-base-dir", + ), + pytest.param( + lambda schema_dir: JSONLDGenerator("tiny.yaml", base_dir=str(schema_dir), metadata=False).serialize(), + id="api-absolute-base-dir", + ), + ], +) +def test_relative_schema_locations(tmp_path, monkeypatch, make_output): + """`gen-jsonld` finds the schema and its relative imports however its location is given. + + Regression test: the nested ContextGenerator was handed the schema path together with + the base_dir SchemaLoader derived from it, so ``dist/tiny.yaml`` was looked up as + ``dist/dist/tiny.yaml``. Forwarding the caller's own base_dir instead must keep an + explicit ``base_dir`` and a sibling ``./core`` import working. + """ + schema_dir = tmp_path / "dist" + schema_dir.mkdir() + (schema_dir / "core.yaml").write_text(CORE_SCHEMA) + (schema_dir / "tiny.yaml").write_text(TINY_SCHEMA) + expected = JSONLDGenerator(str(schema_dir / "tiny.yaml"), metadata=False).serialize() + monkeypatch.chdir(tmp_path) + + output = make_output(schema_dir) + + assert '"name": "Base"' in output + assert output.rstrip() == expected.rstrip() From dc38decc5875ab2b08e0381dc9de557c214db426 Mon Sep 17 00:00:00 2001 From: Corey Cox <69321580+amc-corey-cox@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:13:19 -0500 Subject: [PATCH 05/30] Keep identifier keys out of --use-curies --- .../src/linkml/generators/jsonldcontextgen.py | 9 +- .../src/linkml/generators/jsonschemagen.py | 11 +- .../test_generators/test_jsonldcontextgen.py | 121 ++++++++++++++++++ .../test_generators/test_jsonschemagen.py | 52 ++++++++ 4 files changed, 186 insertions(+), 7 deletions(-) diff --git a/packages/linkml/src/linkml/generators/jsonldcontextgen.py b/packages/linkml/src/linkml/generators/jsonldcontextgen.py index 7f83b6dddf..95c9177f82 100644 --- a/packages/linkml/src/linkml/generators/jsonldcontextgen.py +++ b/packages/linkml/src/linkml/generators/jsonldcontextgen.py @@ -429,7 +429,12 @@ def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None: self._build_element_id(slot_def, slot.slot_uri) self.add_mappings(slot) if slot_def: - if self.use_curies: + if slot.identifier: + # Identifier slots map to the JSON-LD @id keyword. The context key + # must be the slot *name* (the term used in instance data), not a + # CURIE — even when --use-curies is enabled. + key = underscore(aliased_slot_name) + elif self.use_curies: key = self._curie(slot) else: key = underscore(aliased_slot_name) @@ -480,7 +485,7 @@ def _add_if_type_differs(slot_name: str, override_range: str) -> None: self._build_element_id(entry, global_slot.slot_uri) if override_type is not None: entry["@type"] = override_type - if self.use_curies: + if self.use_curies and not global_slot.identifier: scoped[self._curie(global_slot)] = entry else: scoped[underscore(slot_name)] = entry diff --git a/packages/linkml/src/linkml/generators/jsonschemagen.py b/packages/linkml/src/linkml/generators/jsonschemagen.py index 586b21dc4b..289c79515c 100644 --- a/packages/linkml/src/linkml/generators/jsonschemagen.py +++ b/packages/linkml/src/linkml/generators/jsonschemagen.py @@ -679,14 +679,15 @@ def get_subschema_for_anonymous_class( subschema = JsonSchema() for slot in cls.slot_conditions.values(): - if self.use_curies: + # Anonymous slot expressions don't carry the underlying slot's `identifier` or + # `multivalued` flags, so look them up on the schema's slot definition. + base_slot = self.schemaview.get_slot(slot.name) if slot.name else None + if self.use_curies and not (base_slot is not None and base_slot.identifier): prop_name = self._curie(slot) else: prop_name = self.aliased_slot_name(slot) prop = self.get_subschema_for_slot(slot, omit_type=True, include_null=False) - # Anonymous slot expressions don't carry the underlying slot's `multivalued` flag, - # so look it up on the schema's slot definition and wrap so item-level constraints apply. - base_slot = self.schemaview.get_slot(slot.name) if slot.name else None + # Wrap multivalued slots so item-level constraints apply. if base_slot is not None and base_slot.multivalued and not prop.is_array: prop = JsonSchema.array_of(prop, include_null=False, required=False) value_required = False @@ -1023,7 +1024,7 @@ def handle_class_slot(self, subschema: JsonSchema, cls: ClassDefinition, slot: S ) value_disallowed = slot.value_presence == PresenceEnum(PresenceEnum.ABSENT) - if self.use_curies: + if self.use_curies and not slot.identifier: prop_name = self._curie(slot) else: prop_name = self.aliased_slot_name(slot) diff --git a/tests/linkml/test_generators/test_jsonldcontextgen.py b/tests/linkml/test_generators/test_jsonldcontextgen.py index 8aefc013b1..72089eba44 100644 --- a/tests/linkml/test_generators/test_jsonldcontextgen.py +++ b/tests/linkml/test_generators/test_jsonldcontextgen.py @@ -4,6 +4,7 @@ import pytest from click.testing import CliRunner +from rdflib import Graph, Literal, URIRef from linkml.generators import ContextGenerator, JSONLDGenerator from linkml.generators.jsonldcontextgen import ContextGenerator as FrameContextGenerator @@ -1669,3 +1670,123 @@ def test_kitchen_sink_employment_event_type_falls_back(kitchen_sink_path): slot_def = ctx["employed_at"] if isinstance(slot_def, dict) and "@context" in slot_def: assert "@vocab" not in slot_def.get("@context", {}) + + +@pytest.mark.parametrize("use_curies", [True, False]) +def test_identifier_slot_aliases_id(tmp_path, use_curies): + """Identifier slots without slot_uri must alias @id. + + The context key must be the slot name (term used in instance data), NOT a + CURIE, even with --use-curies enabled. An identifier slot's value is the + node's subject IRI and does not produce a predicate triple. + + This is a regression test for issue #4056. + """ + schema_file = tmp_path / "identifier_test.yaml" + schema_file.write_text( + textwrap.dedent( + """ id: https://example.org/identifier-test + name: identifier-test + prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/identifier-test/ + imports: + - linkml:types + default_prefix: ex + default_range: string + classes: + MyClass: + attributes: + id: + identifier: true + range: string + required: true + """ + ) + ) + generator = ContextGenerator(str(schema_file), mergeimports=True, use_curies=use_curies) + ctx = json.loads(generator.serialize())["@context"] + assert ctx.get("id") == "@id" + # no CURIE alias should be emitted for the identifier + assert not any(k.endswith(":id") for k in ctx) + + +@pytest.mark.parametrize("use_curies", [True, False]) +def test_identifier_slot_with_slot_uri_aliases_id(tmp_path, use_curies): + """Identifier slots with slot_uri must still alias @id (slot_uri is ignored). + + Even when a slot_uri is declared on an identifier slot, the context must + still map the slot name to @id and NOT emit the slot_uri as an alias. + This is consistent with LinkML's RDF semantics: identifier values become + the node subject IRI only; no predicate triple is emitted. + """ + schema_file = tmp_path / "identifier_slot_uri_test.yaml" + schema_file.write_text( + textwrap.dedent( + """ id: https://example.org/identifier-slot-uri-test + name: identifier-slot-uri-test + prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/identifier-slot-uri-test/ + sh: http://www.w3.org/ns/shacl# + imports: + - linkml:types + default_prefix: ex + default_range: string + classes: + MyClass: + attributes: + id: + identifier: true + range: string + required: true + slot_uri: sh:focusNode + """ + ) + ) + generator = ContextGenerator(str(schema_file), mergeimports=True, use_curies=use_curies) + ctx = json.loads(generator.serialize())["@context"] + assert ctx.get("id") == "@id" + # no slot_uri key should appear, even with use_curies + assert "sh:focusNode" not in ctx + assert "focusNode" not in ctx + # no CURIE alias should be emitted for the identifier + assert not any(k.endswith(":id") for k in ctx) + + +@pytest.mark.parametrize("use_curies", [True, False]) +def test_identifier_context_round_trips_to_rdf(tmp_path, use_curies): + """Data keyed per the generated context yields triples with the identifier as subject. + + Regression test for https://github.com/linkml/linkml/issues/4056: a CURIE-keyed + ``@id`` alias is invalid JSON-LD 1.1, and rdflib silently produced no triples. + """ + schema_file = tmp_path / "identifier_test.yaml" + schema_file.write_text( + textwrap.dedent( + """ id: https://example.org/identifier-test + name: identifier-test + prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/identifier-test/ + imports: + - linkml:types + default_prefix: ex + default_range: string + classes: + MyClass: + attributes: + id: + identifier: true + name: {} + """ + ) + ) + ctx = json.loads(ContextGenerator(str(schema_file), mergeimports=True, use_curies=use_curies).serialize()) + name_key = "ex:name" if use_curies else "name" + doc = {**ctx, "id": "ex:thing1", name_key: "n"} + + graph = Graph().parse(data=json.dumps(doc), format="json-ld") + + ex = "https://example.org/identifier-test/" + assert set(graph) == {(URIRef(ex + "thing1"), URIRef(ex + "name"), Literal("n"))} diff --git a/tests/linkml/test_generators/test_jsonschemagen.py b/tests/linkml/test_generators/test_jsonschemagen.py index b22dcc7ae3..12bd533962 100644 --- a/tests/linkml/test_generators/test_jsonschemagen.py +++ b/tests/linkml/test_generators/test_jsonschemagen.py @@ -1785,3 +1785,55 @@ def test_top_class_matches_regardless_of_case(tmp_path): assert schema["additionalProperties"] is False assert "name" in schema["properties"] + + +IDENTIFIER_CURIES_SCHEMA = """ +id: https://example.org/identifier-test +name: identifier-test +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/identifier-test/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + id: + identifier: true + name: {} +classes: + MyClass: + slots: [id, name] + rules: + - preconditions: + slot_conditions: + name: + equals_string: x + postconditions: + slot_conditions: + id: + pattern: "^ex:" +""" + + +@pytest.mark.parametrize("use_curies", [True, False]) +def test_identifier_property_keeps_slot_name(tmp_path, use_curies): + """Identifier properties are keyed by slot name, with or without ``--use-curies``. + + Regression test for https://github.com/linkml/linkml/issues/4056: the identifier + maps to the JSON-LD ``@id`` alias, which cannot be CURIE-keyed, so the JSON Schema + must key it the same way the JSON-LD context does. Other slots still use CURIEs. + """ + schema_file = tmp_path / "identifier_test.yaml" + schema_file.write_text(IDENTIFIER_CURIES_SCHEMA) + generated = json.loads(JsonSchemaGenerator(str(schema_file), use_curies=use_curies).serialize()) + cls_def = generated["$defs"]["ex:MyClass" if use_curies else "MyClass"] + name_key = "ex:name" if use_curies else "name" + + assert set(cls_def["properties"]) == {"id", name_key} + assert cls_def["required"] == ["id"] + assert cls_def["then"]["properties"] == {"id": {"pattern": "^ex:"}} + assert cls_def["then"]["required"] == ["id"] + + instance = {"id": "ex:thing1", name_key: "x"} + jsonschema.validate(instance, {**generated, "$ref": f"#/$defs/{'ex:MyClass' if use_curies else 'MyClass'}"}) From 68e7e3d6467d040bccad88216769cd404da0ee41 Mon Sep 17 00:00:00 2001 From: N <13322818+noelmcloughlin@users.noreply.github.com> Date: Thu, 8 Oct 2026 11:11:28 +0100 Subject: [PATCH 06/30] fix(javagen): report a missing --template-file as a usage error (#4076) * fix(javagen): report a missing --template-file as a usage error Signed-off-by: noelmcloughlin * docs(javagen): --config-file reads the whole generator_args.java section Signed-off-by: noelmcloughlin --------- Signed-off-by: noelmcloughlin --- docs/generators/java.rst | 9 ++++---- .../linkml/src/linkml/generators/javagen.py | 1 + tests/linkml/test_generators/test_javagen.py | 21 +++++++++++++++++++ 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/docs/generators/java.rst b/docs/generators/java.rst index fa08565bfd..8c688f5514 100644 --- a/docs/generators/java.rst +++ b/docs/generators/java.rst @@ -135,10 +135,11 @@ since that file is structured to configure every generator at once: java: package: org.example.model -``gen-java`` only ever reads ``generator_args.java.package`` out of this file -- -every other key (``directory``, ``excludes``, other generators' ``generator_args`` -entries, etc.) is ignored, so a full multi-generator project ``config.yaml`` can be -passed as-is without modification. +``gen-java`` reads only the ``generator_args.java`` section of this file (``directory``, +``excludes``, other generators' ``generator_args`` entries, etc. are ignored), so a full +multi-generator project ``config.yaml`` can be passed as-is. Any ``gen-java`` option can +be set there, keyed by its name with dashes as underscores; command-line options take +precedence, and a key that is not an option is reported as a warning and ignored. An explicit ``--package`` command-line option always overrides a value set via ``--config-file``. diff --git a/packages/linkml/src/linkml/generators/javagen.py b/packages/linkml/src/linkml/generators/javagen.py index 2675ba3045..3b8bb12ec9 100644 --- a/packages/linkml/src/linkml/generators/javagen.py +++ b/packages/linkml/src/linkml/generators/javagen.py @@ -614,6 +614,7 @@ def needs_extra_slots(self, klass: ClassDefinition) -> bool: @click.option("--template-variant", help="Use the specified template variant") @click.option( "--template-file", + type=click.Path(exists=True, dir_okay=False), help="""Optional jinja2 template to use for class generation (takes precedence over --template-dir)""", ) diff --git a/tests/linkml/test_generators/test_javagen.py b/tests/linkml/test_generators/test_javagen.py index 1d0d5a3586..a9d04165c4 100644 --- a/tests/linkml/test_generators/test_javagen.py +++ b/tests/linkml/test_generators/test_javagen.py @@ -521,6 +521,27 @@ def test_cli_config_file_typed_option_is_converted(tmp_path): assert_file_contains(out_dir / "Thing.java", "public class Thing", after="package org.example") +@pytest.mark.parametrize("via_config", [False, True]) +def test_cli_missing_template_file_is_a_usage_error(tmp_path, via_config): + """A --template-file that doesn't exist is reported like a missing --template-dir: a + usage error naming the option, not a FileNotFoundError traceback. A config-file value + goes through the same click type, so it is checked the same way.""" + schema_path = _write_minimal_schema(tmp_path / "pkg.yaml") + missing = tmp_path / "missing.jinja2" + if via_config: + config_path = tmp_path / "myconfig.yaml" + config_path.write_text(f"generator_args:\n java:\n template_file: {missing}\n") + extra = ["--config-file", str(config_path)] + else: + extra = ["--template-file", str(missing)] + + result = CliRunner().invoke(cli, [*extra, "--output-directory", str(tmp_path / "out"), str(schema_path)]) + + assert result.exit_code == 2 + assert "--template-file" in result.output + assert "does not exist" in result.output + + def test_cli_config_file_unknown_key_is_warned_not_injected(tmp_path, caplog): """A key click never exposes (`version`, from --version) is a config typo: warn and skip it, rather than passing a kwarg the generator's __init__ would reject.""" From 008c12dbf143ceda1e544c1a9173b478b0bf55dd Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Tue, 8 Sep 2026 18:09:43 +0200 Subject: [PATCH 07/30] feat(rdf): add opt-in diff-stable blank-node labels RDFC-1.0 canonicalization already makes RDF output deterministic: isomorphic graphs always serialize identically. It does not make output diffable. Blank nodes are numbered `c14nN` in a single global order, so inserting one class can renumber every blank node after it and rewrite most of the file. A one-line semantic change lands as a whole-file diff, which makes generated OWL/SHACL hard to review and noisy to keep under version control. Add a `diff_stable` argument to `canonicalize_rdf_graph()` and a `--diff-stable/--no-diff-stable` flag to the four RDF generators. When enabled, blank-node labels are derived from each node's own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels only the blank nodes it actually touches. Measured churn on a real schema (add one class, count changed lines): generator default --diff-stable owlgen 2091 17 shexgen 796 50 shaclgen 291 13 rdfgen 115 25 Output stays deterministic and isomorphic either way; only the choice of label changes. Off by default, because enabling it relabels existing output. The refinement itself lives in `diffable-rdf`, whose only dependencies (rdflib, pyoxigraph) are already linkml-runtime dependencies at higher versions, so this adds no new transitive dependencies. --- .../linkml/src/linkml/generators/owlgen.py | 28 +++- .../linkml/src/linkml/generators/rdfgen.py | 28 +++- .../linkml/src/linkml/generators/shaclgen.py | 28 +++- .../linkml/src/linkml/generators/shexgen.py | 28 +++- packages/linkml_runtime/pyproject.toml | 1 + .../linkml_runtime/utils/rdf_canonicalize.py | 23 ++- .../test_utils/test_rdf_canonicalize.py | 135 ++++++++++++++++++ 7 files changed, 266 insertions(+), 5 deletions(-) diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 7ba15df672..09db0357be 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -122,6 +122,22 @@ class OwlSchemaGenerator(Generator): """Suffix to add to the schema name to create the ontology URI, e.g. .owl.ttl""" # ObjectVars + diff_stable: bool = False + """Label blank nodes so that unrelated edits leave them untouched. + + Output is already deterministic: RDFC-1.0 guarantees that isomorphic + graphs serialize identically. It does not guarantee that *similar* + graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a + global order, so adding one class can renumber every blank node after + it and rewrite most of the file. + + When ``True``, blank-node labels are instead derived from each node's + own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels + only the blank nodes it actually touches. The output stays + deterministic and isomorphic either way; only the choice of label + changes. Off by default because enabling it relabels existing output. + """ + metadata_profile: MetadataProfile | None = None """Deprecated - use metadata_profiles.""" @@ -353,7 +369,7 @@ def serialize(self, **kwargs: Any) -> str: """ self.as_graph() fmt = "turtle" if self.format in ["owl", "ttl"] else self.format - return canonicalize_rdf_graph(self.graph, output_format=fmt) + return canonicalize_rdf_graph(self.graph, output_format=fmt, diff_stable=self.diff_stable) def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None: """ @@ -1844,6 +1860,16 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef: "specified language tag. Element-level in_language overrides this." ), ) +@click.option( + "--diff-stable/--no-diff-stable", + default=False, + show_default=True, + help=( + "Derive blank-node labels from each node's own neighbourhood so that " + "unrelated edits leave them unchanged. Output is deterministic either " + "way; this makes successive versions of a file diff cleanly." + ), +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile: str, metadata_profile: str, **kwargs: Any) -> None: """Generate an OWL representation of a LinkML model diff --git a/packages/linkml/src/linkml/generators/rdfgen.py b/packages/linkml/src/linkml/generators/rdfgen.py index 2da1701787..052fcff12e 100644 --- a/packages/linkml/src/linkml/generators/rdfgen.py +++ b/packages/linkml/src/linkml/generators/rdfgen.py @@ -78,6 +78,22 @@ class RDFGenerator(Generator): uses_schemaloader = True # ObjectVars + diff_stable: bool = False + """Label blank nodes so that unrelated edits leave them untouched. + + Output is already deterministic: RDFC-1.0 guarantees that isomorphic + graphs serialize identically. It does not guarantee that *similar* + graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a + global order, so adding one class can renumber every blank node after + it and rewrite most of the file. + + When ``True``, blank-node labels are instead derived from each node's + own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels + only the blank nodes it actually touches. The output stays + deterministic and isomorphic either way; only the choice of label + changes. Off by default because enabling it relabels existing output. + """ + emit_metadata: bool = False context: list[str] = None original_schema: SchemaDefinition = None @@ -89,7 +105,7 @@ def __post_init__(self): def _data(self, g: Graph) -> str: fmt = "turtle" if self.format == "ttl" else self.format - return canonicalize_rdf_graph(g, output_format=fmt) + return canonicalize_rdf_graph(g, output_format=fmt, diff_stable=self.diff_stable) def end_schema(self, output: str | None = None, context: str = None, **_) -> str: gen = JSONLDGenerator( @@ -137,6 +153,16 @@ def end_schema(self, output: str | None = None, context: str = None, **_) -> str multiple=True, help="JSONLD context file (default: vendored meta.context.jsonld)", ) +@click.option( + "--diff-stable/--no-diff-stable", + default=False, + show_default=True, + help=( + "Derive blank-node labels from each node's own neighbourhood so that " + "unrelated edits leave them unchanged. Output is deterministic either " + "way; this makes successive versions of a file diff cleanly." + ), +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile, **kwargs): """Generate an RDF representation of a LinkML model""" diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 4731b9f0b8..5d42fea272 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -142,6 +142,22 @@ class ShaclGenerator(Generator): ignores any per-slot ``in_language``. """ + diff_stable: bool = False + """Label blank nodes so that unrelated edits leave them untouched. + + Output is already deterministic: RDFC-1.0 guarantees that isomorphic + graphs serialize identically. It does not guarantee that *similar* + graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a + global order, so adding one class can renumber every blank node after + it and rewrite most of the file. + + When ``True``, blank-node labels are instead derived from each node's + own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels + only the blank nodes it actually touches. The output stays + deterministic and isomorphic either way; only the choice of label + changes. Off by default because enabling it relabels existing output. + """ + emit_rules: bool = True """Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks. @@ -196,7 +212,7 @@ def generate_header(self) -> str: def serialize(self, **args) -> str: g = self.as_graph() fmt = "turtle" if self.format in ["owl", "ttl"] else self.format - return canonicalize_rdf_graph(g, output_format=fmt) + return canonicalize_rdf_graph(g, output_format=fmt, diff_stable=self.diff_stable) def as_graph(self) -> Graph: sv = self.schemaview @@ -929,6 +945,16 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None: "sh:NodeShape. Use --no-emit-rules to suppress rule generation." ), ) +@click.option( + "--diff-stable/--no-diff-stable", + default=False, + show_default=True, + help=( + "Derive blank-node labels from each node's own neighbourhood so that " + "unrelated edits leave them unchanged. Output is deterministic either " + "way; this makes successive versions of a file diff cleanly." + ), +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile, **args): """Generate SHACL turtle from a LinkML model""" diff --git a/packages/linkml/src/linkml/generators/shexgen.py b/packages/linkml/src/linkml/generators/shexgen.py index 40a93ffbc9..7c43b13bc0 100644 --- a/packages/linkml/src/linkml/generators/shexgen.py +++ b/packages/linkml/src/linkml/generators/shexgen.py @@ -40,6 +40,22 @@ class ShExGenerator(Generator): uses_schemaloader = True # ObjectVars + diff_stable: bool = False + """Label blank nodes so that unrelated edits leave them untouched. + + Output is already deterministic: RDFC-1.0 guarantees that isomorphic + graphs serialize identically. It does not guarantee that *similar* + graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a + global order, so adding one class can renumber every blank node after + it and rewrite most of the file. + + When ``True``, blank-node labels are instead derived from each node's + own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels + only the blank nodes it actually touches. The output stays + deterministic and isomorphic either way; only the choice of label + changes. Off by default because enabling it relabels existing output. + """ + shex: Schema = field(default_factory=lambda: Schema()) # ShEx Schema being generated shapes: list = field(default_factory=lambda: []) shape: Shape | None = None # Current shape being defined @@ -177,7 +193,7 @@ def end_schema(self, output: str | None = None, **_) -> str: g = Graph() g.parse(data=shex, format="json-ld", version="1.1") g.bind("owl", OWL) - shex = canonicalize_rdf_graph(g, output_format="turtle") + shex = canonicalize_rdf_graph(g, output_format="turtle", diff_stable=self.diff_stable) elif self.format == "shex": g = Graph() self.namespaces.load_graph(g) @@ -258,6 +274,16 @@ def _get_subproperty_values(self, slot: SlotDefinition) -> list: help="If --expand-subproperty-of (default), slots with subproperty_of will generate NodeConstraint " "values containing all slot descendants. Use --no-expand-subproperty-of to disable this behavior.", ) +@click.option( + "--diff-stable/--no-diff-stable", + default=False, + show_default=True, + help=( + "Derive blank-node labels from each node's own neighbourhood so that " + "unrelated edits leave them unchanged. Output is deterministic either " + "way; this makes successive versions of a file diff cleanly." + ), +) @click.version_option(__version__, "-V", "--version") def cli(yamlfile, **args): """Generate a ShEx Schema for a LinkML model""" diff --git a/packages/linkml_runtime/pyproject.toml b/packages/linkml_runtime/pyproject.toml index 5f69503d48..1324790b58 100644 --- a/packages/linkml_runtime/pyproject.toml +++ b/packages/linkml_runtime/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "prefixmaps >=0.1.4", "curies>=0.14.6", "pyoxigraph>=0.5.11", + "diffable-rdf>=0.2.0", "pydantic>=2.13.5,<3.0.0", "isodate >=0.7.2, <1.0.0; python_version < '3.11'", ] diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py index 31c5580348..e0fadae34a 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py @@ -42,6 +42,7 @@ import pyoxigraph as ox import rdflib +from diffable_rdf import wl_relabel_quads from rdflib.compare import to_canonical_graph @@ -287,6 +288,7 @@ def _is_safe_prefix_iri(iri: str) -> bool: def canonicalize_rdf_graph( graph: rdflib.Graph, output_format: str = "turtle", + diff_stable: bool = False, ) -> str: """Serialize an rdflib Graph deterministically using RDFC-1.0 canonicalization. @@ -300,6 +302,12 @@ def canonicalize_rdf_graph( :param graph: The rdflib Graph to serialize. :param output_format: Target serialization format (e.g. ``"turtle"``, ``"nt"``). + :param diff_stable: Derive blank-node labels from each node's own + neighbourhood instead of RDFC-1.0's global ``c14nN`` numbering, so that + editing one part of a schema does not renumber unrelated blank nodes. + Output is deterministic and isomorphic either way; only the choice of + label changes. Off by default because enabling it relabels existing + output. :return: Deterministic string serialization of the graph. """ ox_format = _FORMAT_MAP.get(output_format.lower()) @@ -339,13 +347,26 @@ def canonicalize_rdf_graph( # 3. Canonicalize blank node labels with RDFC-1.0. dataset.canonicalize(ox.CanonicalizationAlgorithm.RDFC_1_0) + quads = list(dataset) + + # 3b. Optionally re-label blank nodes with locality-sensitive hashes. + # RDFC-1.0 guarantees that identical graphs get identical labels, but it + # does not guarantee that *similar* graphs get similar labels: the labels + # are assigned by a global ordering, so inserting one blank node can + # renumber every label after it and turn a one-line semantic change into a + # whole-file diff. Weisfeiler-Lehman labels are derived only from each + # node's local neighbourhood, so unrelated regions keep their labels. + # Output stays deterministic and isomorphic either way; this only changes + # which label each blank node receives. + if diff_stable: + quads = wl_relabel_quads(quads) + # 4. Sort triples for deterministic ordering. # RDFC-1.0 stabilizes blank-node labels but pyoxigraph's Dataset # iteration order is not sorted and varies across processes (verified # empirically against pyoxigraph 0.5.8). The explicit string-key sort # is load-bearing for byte-identical output across runs; see # tests/linkml_runtime/test_utils/test_rdf_canonicalize.py::test_sort_is_load_bearing. - quads = list(dataset) sorted_triples = sorted( (ox.Triple(q.subject, q.predicate, q.object) for q in quads), key=lambda t: (str(t.subject), str(t.predicate), str(t.object)), diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py index 63dbf095a1..0ca79c3c90 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py @@ -507,3 +507,138 @@ def run(seed: str) -> str: assert out_a == out_b, ( "Fallback output differs across PYTHONHASHSEED values; blank-node canonicalization may be missing" ) + + +def _shapes_graph(count: int, extra: bool = False) -> Graph: + """A graph shaped like generator output: one blank node per named subject.""" + g = Graph() + g.bind("ex", "http://example.com/") + names = [f"{i:02d}" for i in range(count)] + (["AAAinserted"] if extra else []) + for name in names: + subject = URIRef(f"http://example.com/Shape{name}") + prop = BNode() + g.add((subject, URIRef("http://example.com/property"), prop)) + g.add((prop, URIRef("http://example.com/path"), URIRef(f"http://example.com/p{name}"))) + return g + + +def _changed_line_count(before: str, after: str) -> int: + import difflib + + diff = difflib.unified_diff(before.splitlines(), after.splitlines(), n=0, lineterm="") + return sum(1 for line in diff if line[:1] in "+-" and not line.startswith(("+++", "---"))) + + +def test_diff_stable_is_opt_in(): + """The default must keep producing exactly the output it produced before.""" + graph = _make_graph_with_bnodes() + assert canonicalize_rdf_graph(graph) == canonicalize_rdf_graph(graph, diff_stable=False) + + +def test_diff_stable_preserves_semantics(): + """Relabelling blank nodes must not change what the graph means.""" + graph = _make_graph_with_bnodes() + + plain = rdflib.Graph() + plain.parse(data=canonicalize_rdf_graph(graph), format="turtle") + stable = rdflib.Graph() + stable.parse(data=canonicalize_rdf_graph(graph, diff_stable=True), format="turtle") + + assert rdflib.compare.isomorphic(plain, stable) + + +def test_diff_stable_is_deterministic(): + """Diff stability must not cost determinism, which is the stronger property.""" + graph = _make_graph_with_bnodes() + outputs = {canonicalize_rdf_graph(graph, diff_stable=True) for _ in range(5)} + assert len(outputs) == 1 + + +def test_diff_stable_confines_an_insertion_to_the_lines_it_touches(): + """Inserting one subject must not relabel the blank nodes of the others. + + RDFC-1.0 numbers blank nodes ``c14nN`` in a global order, so a subject + sorting before the others shifts every subsequent label and rewrites + most of the file. This is the entire reason the option exists, so the + assertion is on the *ratio*, not on an absolute line count that would + be brittle across rdflib versions. + """ + before, after = _shapes_graph(20), _shapes_graph(20, extra=True) + + baseline = _changed_line_count(canonicalize_rdf_graph(before), canonicalize_rdf_graph(after)) + stable = _changed_line_count( + canonicalize_rdf_graph(before, diff_stable=True), + canonicalize_rdf_graph(after, diff_stable=True), + ) + + assert stable < baseline / 4, f"expected diff-stable output to churn far less; got {stable} vs baseline {baseline}" + + +_DIFF_STABLE_SCHEMA = """\ +id: https://example.org/diffstable +name: diffstable +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/diffstable/ +default_prefix: ex +default_range: string +imports: + - linkml:types +classes: + Person: + slots: [name, knows] + Organization: + slots: [name] +slots: + name: + range: string + knows: + range: Person + multivalued: true +""" + + +def _generator_cases(): + """The four generators that serialize RDF, with the args that make them do so.""" + from linkml.generators.owlgen import OwlSchemaGenerator + from linkml.generators.rdfgen import RDFGenerator + from linkml.generators.shaclgen import ShaclGenerator + from linkml.generators.shexgen import ShExGenerator + + return [ + pytest.param(OwlSchemaGenerator, {}, id="owlgen"), + # rdfgen and shexgen resolve JSON-LD contexts (linkml types, shex.jsonld); + # the `network` marker serves those from local stubs. See tests/conftest.py. + pytest.param(RDFGenerator, {}, id="rdfgen", marks=pytest.mark.network), + pytest.param(ShaclGenerator, {}, id="shaclgen"), + # ShExGenerator only emits RDF in this format; its default is ShExC text, + # where blank-node labelling does not apply. + pytest.param(ShExGenerator, {"format": "rdf"}, id="shexgen", marks=pytest.mark.network), + ] + + +@pytest.mark.parametrize(("generator", "kwargs"), _generator_cases()) +def test_diff_stable_reaches_every_rdf_generator(tmp_path, generator, kwargs): + """Every RDF generator must actually apply the option, not merely accept it. + + Asserting only that the attribute exists would pass even if a generator + forgot to pass it down to :func:`canonicalize_rdf_graph`. Instead this + checks the observable consequence: RDFC-1.0 names blank nodes ``c14nN``, + while diff-stable labels are neighbourhood hashes, so a generator that + honours the flag emits no ``c14nN`` label at all. + """ + assert generator.diff_stable is False, f"{generator.__name__} must default to off" + + schema = tmp_path / "schema.yaml" + schema.write_text(_DIFF_STABLE_SCHEMA, encoding="utf-8", newline="\n") + + plain = generator(str(schema), **kwargs).serialize() + stable = generator(str(schema), diff_stable=True, **kwargs).serialize() + + # Guards the test itself: if the fixture stopped producing blank nodes the + # assertion below would hold vacuously. + assert re.search(r"c14n\d+", plain), f"{generator.__name__} output has no blank nodes to relabel" + assert not re.search(r"c14n\d+", stable), ( + f"{generator.__name__} still emits RDFC-1.0 blank-node labels with diff_stable=True; " + "the flag is probably not threaded into canonicalize_rdf_graph()" + ) From 4a9a7b233466ca34d1a2351ff401dcaed0f4a2f6 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 11 Sep 2026 10:52:51 +0200 Subject: [PATCH 08/30] fix(rdf): bump diffable-rdf to 0.3.0 and stop diff_stable silently no-opping Bump the floor to diffable-rdf 0.3.0 and add the missing uv.lock entry: the dependency was declared in pyproject.toml but never locked, so "uv lock --check" and the "uv sync --frozen" anti-malware gate would both have failed CI. 0.3.0 also fixes two defects in the Weisfeiler-Lehman labelling this feature relies on. Disconnected blank-node components now converge independently, so an edit in one region no longer relabels an unrelated one. And the suffix used to tell structurally indistinguishable nodes apart was assigned in c14nN *text* order, so c14n10 sorted between c14n1 and c14n2 -- adding a tenth tied blank node relabelled eight of the nine already there, the exact opposite of what this labelling is for. Separately, diff_stable=True was silently ignored whenever pyoxigraph refused the graph and canonicalize_rdf_graph degraded to rdflib. Weisfeiler-Lehman refinement consumes canonical pyoxigraph quads, and that path exists precisely because there are none, so the argument could not be honoured -- but the caller was never told. "shaclgen --include-annotations --diff-stable" reaches it, via the literal predicate an annotation tag without a ':' produces, and returned output byte-identical to --no-diff-stable. It now warns, with a regression test asserting the warning and the byte-identical output that makes silence misleading. --- packages/linkml_runtime/pyproject.toml | 2 +- .../linkml_runtime/utils/rdf_canonicalize.py | 20 ++++++++++++++- .../test_utils/test_rdf_canonicalize.py | 25 +++++++++++++++++++ uv.lock | 15 +++++++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/packages/linkml_runtime/pyproject.toml b/packages/linkml_runtime/pyproject.toml index 1324790b58..9c555f4ace 100644 --- a/packages/linkml_runtime/pyproject.toml +++ b/packages/linkml_runtime/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "prefixmaps >=0.1.4", "curies>=0.14.6", "pyoxigraph>=0.5.11", - "diffable-rdf>=0.2.0", + "diffable-rdf>=0.3.0", "pydantic>=2.13.5,<3.0.0", "isodate >=0.7.2, <1.0.0; python_version < '3.11'", ] diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py index e0fadae34a..8e28488d55 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py @@ -307,7 +307,8 @@ def canonicalize_rdf_graph( editing one part of a schema does not renumber unrelated blank nodes. Output is deterministic and isomorphic either way; only the choice of label changes. Off by default because enabling it relabels existing - output. + output. Has no effect on the rdflib fallback path (non-standard RDF), + which warns rather than silently ignoring the request. :return: Deterministic string serialization of the graph. """ ox_format = _FORMAT_MAP.get(output_format.lower()) @@ -338,6 +339,23 @@ def canonicalize_rdf_graph( RDFCanonicalizationWarning, stacklevel=2, ) + if diff_stable: + # Weisfeiler-Lehman refinement consumes canonical pyoxigraph quads, + # and this path exists precisely because pyoxigraph refused the + # graph, so there are none to refine. The fallback is deterministic + # but not diff-stable: say so rather than returning output that + # silently ignores the argument. ``shaclgen --include-annotations`` + # reaches this path, because an annotation tag without a ``:`` + # becomes a literal predicate. + warnings.warn( + "diff_stable=True was requested but this graph took the rdflib fallback, " + "which cannot apply Weisfeiler-Lehman blank-node labels. Output is " + "deterministic but NOT diff-stable: an unrelated edit may still renumber " + "blank nodes. Make the offending terms standard RDF (absolute IRIs, IRI " + "predicates) to get diff-stable labels.", + RDFCanonicalizationWarning, + stacklevel=2, + ) return _deterministic_fallback_serialize(graph, output_format) dataset = ox.Dataset() diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py index 0ca79c3c90..1bc07583af 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py @@ -642,3 +642,28 @@ def test_diff_stable_reaches_every_rdf_generator(tmp_path, generator, kwargs): f"{generator.__name__} still emits RDFC-1.0 blank-node labels with diff_stable=True; " "the flag is probably not threaded into canonicalize_rdf_graph()" ) + + +def test_diff_stable_warns_instead_of_silently_no_opping_on_the_fallback(): + """A request the fallback cannot honour must be reported, not ignored. + + ``wl_relabel_quads`` consumes canonical pyoxigraph quads, and the rdflib + fallback exists precisely because pyoxigraph refused the graph. Returning + the same bytes for ``diff_stable=True`` and ``diff_stable=False`` without + saying so lets a caller believe the output is diff-stable when it is not. + """ + graph = _make_graph_with_bnodes() + # A relative IRI is non-standard RDF, so pyoxigraph rejects the graph and + # canonicalize_rdf_graph degrades to rdflib -- the same path that + # ``shaclgen --include-annotations`` takes via its literal predicates. + graph.add((URIRef("testing"), URIRef("http://example.com/p"), Literal("v"))) + + with pytest.warns(RDFCanonicalizationWarning, match="NOT diff-stable"): + stable = canonicalize_rdf_graph(graph, diff_stable=True) + + with pytest.warns(RDFCanonicalizationWarning): + plain = canonicalize_rdf_graph(graph, diff_stable=False) + + # The warning is the contract: the bytes really are identical, which is + # exactly why staying silent would be misleading. + assert stable == plain diff --git a/uv.lock b/uv.lock index 89ec70cfc5..05cca6a5cf 100644 --- a/uv.lock +++ b/uv.lock @@ -1075,6 +1075,19 @@ 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 = "diffable-rdf" +version = "0.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyoxigraph" }, + { name = "rdflib" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fe/f3/45771c536aea850f2884206349e35910bbabcaebeec890c5c0cc08b040f9/diffable_rdf-0.3.0.tar.gz", hash = "sha256:6fcf84d9323cd35fc75f423bd73836469cfad198ebb7e6ab86a8743724cd000d", size = 829395, upload-time = "2026-09-11T08:00:26.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/27/22/ef80dba96cd558f59d2ab225602bde318a925d437866379303e1f41a4e96/diffable_rdf-0.3.0-py3-none-any.whl", hash = "sha256:8756137423f9f28beacb5cd99ba8d66317328a0c936b1dbb4527831c7d9c6784", size = 43578, upload-time = "2026-09-11T08:00:24.542Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -2609,6 +2622,7 @@ dependencies = [ { name = "click" }, { name = "curies" }, { name = "deprecated" }, + { name = "diffable-rdf" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'" }, { name = "json-flattener" }, @@ -2641,6 +2655,7 @@ requires-dist = [ { name = "coverage", marker = "extra == 'dev'" }, { name = "curies", specifier = ">=0.14.6" }, { name = "deprecated" }, + { name = "diffable-rdf", specifier = ">=0.3.0" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'", specifier = ">=0.7.2,<1.0.0" }, { name = "json-flattener", specifier = ">=0.1.9" }, From 9b3257079e734cd437f536b73ee4dce372d34c57 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 11 Sep 2026 13:45:48 +0200 Subject: [PATCH 09/30] build(deps): require diffable-rdf 0.4.0 0.4.0 carries graph.base through the library's rdflib fallback, verifying that every absolute IRI of the source survives a re-read rather than dropping the directive outright, and adds a diff_stable parameter to canonicalize_rdf_graph. The lock entry is written by hand because the workspace sets exclude-newer = "7 days", which filters any release younger than that from resolution; 0.3.0 was pinned the same way for the same reason, and both become resolvable normally on 2026-09-18. uv lock --check and uv sync --all-groups both accept the entry. https://github.com/ASCS-eV/diffable-rdf/releases/tag/v0.4.0 --- packages/linkml_runtime/pyproject.toml | 2 +- uv.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/linkml_runtime/pyproject.toml b/packages/linkml_runtime/pyproject.toml index 9c555f4ace..7509ef60a6 100644 --- a/packages/linkml_runtime/pyproject.toml +++ b/packages/linkml_runtime/pyproject.toml @@ -48,7 +48,7 @@ dependencies = [ "prefixmaps >=0.1.4", "curies>=0.14.6", "pyoxigraph>=0.5.11", - "diffable-rdf>=0.3.0", + "diffable-rdf>=0.4.0", "pydantic>=2.13.5,<3.0.0", "isodate >=0.7.2, <1.0.0; python_version < '3.11'", ] diff --git a/uv.lock b/uv.lock index 05cca6a5cf..a313d1e298 100644 --- a/uv.lock +++ b/uv.lock @@ -1077,15 +1077,15 @@ wheels = [ [[package]] name = "diffable-rdf" -version = "0.3.0" +version = "0.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pyoxigraph" }, { name = "rdflib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fe/f3/45771c536aea850f2884206349e35910bbabcaebeec890c5c0cc08b040f9/diffable_rdf-0.3.0.tar.gz", hash = "sha256:6fcf84d9323cd35fc75f423bd73836469cfad198ebb7e6ab86a8743724cd000d", size = 829395, upload-time = "2026-09-11T08:00:26.268Z" } +sdist = { url = "https://files.pythonhosted.org/packages/86/df/1d71c0c984eac1cfd25531aabf84528941b01083c875da49d44882d1b5af/diffable_rdf-0.4.0.tar.gz", hash = "sha256:a84afaa10332e6a039b6ddcabf76bced3049e4da467c88ee786d0fa35218111e", size = 838040, upload-time = "2026-09-11T11:43:12.844Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/22/ef80dba96cd558f59d2ab225602bde318a925d437866379303e1f41a4e96/diffable_rdf-0.3.0-py3-none-any.whl", hash = "sha256:8756137423f9f28beacb5cd99ba8d66317328a0c936b1dbb4527831c7d9c6784", size = 43578, upload-time = "2026-09-11T08:00:24.542Z" }, + { url = "https://files.pythonhosted.org/packages/7f/49/b62ee864fe6769b759f04e3709fd79711b1cdf49223b1bb0e985e507ead3/diffable_rdf-0.4.0-py3-none-any.whl", hash = "sha256:c59c57429465f786fdbc8d38ddbb687da16748c5752f0c2ac4a77b5527aa9aed", size = 46126, upload-time = "2026-09-11T11:43:11.536Z" }, ] [[package]] @@ -2655,7 +2655,7 @@ requires-dist = [ { name = "coverage", marker = "extra == 'dev'" }, { name = "curies", specifier = ">=0.14.6" }, { name = "deprecated" }, - { name = "diffable-rdf", specifier = ">=0.3.0" }, + { name = "diffable-rdf", specifier = ">=0.4.0" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'", specifier = ">=0.7.2,<1.0.0" }, { name = "json-flattener", specifier = ">=0.1.9" }, From e99dec2990c23a115d42b58a0e928824089ea8c3 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Mon, 14 Sep 2026 11:19:02 +0200 Subject: [PATCH 10/30] test(rdf): mark diff-stable tests for diffable-rdf canary CI Registers a diffable_rdf pytest marker and applies it to the six diff_stable tests (including the parametrized per-generator test), so an external CI job can run 'pytest -m diffable_rdf' against a local diffable-rdf checkout without pulling in the rest of the suite. Follow-up to a review comment on this PR requesting canary tests from linkml running in diffable-rdf's own CI, modeled on the numpydantic project's tests-linkml.yml workflow. --- pyproject.toml | 1 + tests/linkml_runtime/test_utils/test_rdf_canonicalize.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 559cf265ac..fd62dc2986 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -115,6 +115,7 @@ markers = [ "yarrrml: End-to-end tests for the YARRRML generator", "typedbgen: Tests for the TypeDB generator", "arrays: Array specifications!", + "diffable_rdf: tests that exercise the diffable-rdf integration (--diff-stable); run as a canary against linkml in diffable-rdf's own CI", ] # https://docs.astral.sh/ruff/settings/ diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py index 1bc07583af..9fc94d38d4 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py @@ -529,12 +529,14 @@ def _changed_line_count(before: str, after: str) -> int: return sum(1 for line in diff if line[:1] in "+-" and not line.startswith(("+++", "---"))) +@pytest.mark.diffable_rdf def test_diff_stable_is_opt_in(): """The default must keep producing exactly the output it produced before.""" graph = _make_graph_with_bnodes() assert canonicalize_rdf_graph(graph) == canonicalize_rdf_graph(graph, diff_stable=False) +@pytest.mark.diffable_rdf def test_diff_stable_preserves_semantics(): """Relabelling blank nodes must not change what the graph means.""" graph = _make_graph_with_bnodes() @@ -547,6 +549,7 @@ def test_diff_stable_preserves_semantics(): assert rdflib.compare.isomorphic(plain, stable) +@pytest.mark.diffable_rdf def test_diff_stable_is_deterministic(): """Diff stability must not cost determinism, which is the stronger property.""" graph = _make_graph_with_bnodes() @@ -554,6 +557,7 @@ def test_diff_stable_is_deterministic(): assert len(outputs) == 1 +@pytest.mark.diffable_rdf def test_diff_stable_confines_an_insertion_to_the_lines_it_touches(): """Inserting one subject must not relabel the blank nodes of the others. @@ -617,6 +621,7 @@ def _generator_cases(): ] +@pytest.mark.diffable_rdf @pytest.mark.parametrize(("generator", "kwargs"), _generator_cases()) def test_diff_stable_reaches_every_rdf_generator(tmp_path, generator, kwargs): """Every RDF generator must actually apply the option, not merely accept it. @@ -644,6 +649,7 @@ def test_diff_stable_reaches_every_rdf_generator(tmp_path, generator, kwargs): ) +@pytest.mark.diffable_rdf def test_diff_stable_warns_instead_of_silently_no_opping_on_the_fallback(): """A request the fallback cannot honour must be reported, not ignored. From 3aaae35bff5bf180e1561ea8da17b95968055ef3 Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 16 Sep 2026 07:19:42 +0200 Subject: [PATCH 11/30] Make diff-stable serialization optional and address review feedback Expose runtime and LinkML extras with a lazy import and an installation hint. Move usage guidance into the collaboration guide and shorten generator help. Assert designed RDF edits, source preservation, generator output and CLI flags. Check ordinary and extra wheel installs in the existing package build job. --- .github/tests/test_diff_stable_install.py | 147 ++++++++++ .github/workflows/main.yaml | 26 ++ docs/howtos/collaborative-development.md | 30 ++ packages/linkml/pyproject.toml | 4 + .../linkml/src/linkml/generators/owlgen.py | 20 +- .../linkml/src/linkml/generators/rdfgen.py | 20 +- .../linkml/src/linkml/generators/shaclgen.py | 20 +- .../linkml/src/linkml/generators/shexgen.py | 20 +- packages/linkml_runtime/pyproject.toml | 2 +- .../linkml_runtime/utils/rdf_canonicalize.py | 37 +-- tests/linkml/test_scripts/test_diff_stable.py | 99 +++++++ .../test_utils/test_rdf_canonicalize.py | 271 ++++++++++++------ uv.lock | 139 +++++---- 13 files changed, 585 insertions(+), 250 deletions(-) create mode 100644 .github/tests/test_diff_stable_install.py create mode 100644 tests/linkml/test_scripts/test_diff_stable.py diff --git a/.github/tests/test_diff_stable_install.py b/.github/tests/test_diff_stable_install.py new file mode 100644 index 0000000000..ef2927ccf4 --- /dev/null +++ b/.github/tests/test_diff_stable_install.py @@ -0,0 +1,147 @@ +"""Run each selection in a fresh environment containing the built wheels.""" + +import importlib +import re +import sys +import sysconfig +from importlib.metadata import metadata, version +from importlib.util import find_spec +from pathlib import Path + +import pytest +from packaging.requirements import Requirement + +_SCHEMA = """id: https://example.org/install +name: install +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/install/ +default_prefix: ex +imports: [linkml:types] +classes: + Thing: + attributes: + label: + range: string +""" + + +def _assert_installed(package: str) -> None: + """Require imports from this environment's installed package directory.""" + module = importlib.import_module(package) + assert Path(module.__file__).resolve().is_relative_to(Path(sysconfig.get_path("purelib")).resolve()) + + +def _assert_runtime_requirements() -> None: + """Check ordinary compatibility and the extra's forwarding in wheel metadata.""" + requirements = [Requirement(value) for value in metadata("linkml").get_all("Requires-Dist")] + (ordinary,) = [r for r in requirements if r.name == "linkml-runtime" and r.marker is None] + (forwarded,) = [r for r in requirements if r.name == "linkml-runtime" and r.marker is not None] + assert ordinary.extras == set() + assert forwarded.extras == {"diff-stable"} + assert forwarded.marker.evaluate({"extra": "diff-stable"}) + assert not forwarded.marker.evaluate({"extra": ""}) + assert ordinary.specifier == forwarded.specifier + assert ordinary.specifier.contains(version("linkml-runtime"), prereleases=True) + + +def _assert_diff_stable_labels(output: str) -> None: + """Require actual blank-node terms with the enabled label syntax.""" + import pyoxigraph as ox + + quads = ox.parse(output, format=ox.RdfFormat.TURTLE) + nodes = {term for q in quads for term in (q.subject, q.object) if isinstance(term, ox.BlankNode)} + assert nodes + assert all(re.fullmatch(r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?", node.value) for node in nodes) + + +def test_runtime_without_extra() -> None: + """Ordinary runtime installs work and explain how to enable the option.""" + assert find_spec("diffable_rdf") is None + _assert_installed("linkml_runtime") + from rdflib import Graph + from rdflib.compare import isomorphic + + from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph + + graph = Graph().parse(data=' [ "v" ] .') + for kwargs in ({}, {"diff_stable": False}): + output = canonicalize_rdf_graph(graph, **kwargs) + assert isomorphic(graph, Graph().parse(data=output, format="turtle")) + assert "diffable_rdf" not in sys.modules + with pytest.raises(ImportError, match=r"linkml-runtime\[diff-stable\]"): + canonicalize_rdf_graph(graph, diff_stable=True) + + +def test_linkml_without_extra(tmp_path: Path) -> None: + """Generator imports, help and default output do not require the extra.""" + assert find_spec("diffable_rdf") is None + _assert_installed("linkml_runtime") + _assert_installed("linkml") + _assert_runtime_requirements() + from click.testing import CliRunner + from rdflib import Graph + from rdflib.namespace import RDF, SH + + from linkml.generators.shaclgen import cli + + for name in ("owlgen", "rdfgen", "shaclgen", "shexgen"): + command = importlib.import_module(f"linkml.generators.{name}").cli + result = CliRunner().invoke(command, ["--help"]) + assert result.exit_code == 0, result.output + schema = tmp_path / "schema.yaml" + schema.write_text(_SCHEMA, encoding="utf-8") + result = CliRunner().invoke(cli, [str(schema)]) + assert result.exit_code == 0, result.output + graph = Graph().parse(data=result.stdout, format="turtle") + assert list(graph.subjects(RDF.type, SH.NodeShape)) + assert "diffable_rdf" not in sys.modules + result = CliRunner().invoke(cli, [str(schema), "--diff-stable"]) + assert result.exit_code != 0 + assert isinstance(result.exception, ImportError) + assert "linkml[diff-stable]" in str(result.exception) + + +def test_runtime_with_extra() -> None: + """The runtime extra supplies the relabeler and preserves the source graph.""" + assert find_spec("diffable_rdf") is not None + _assert_installed("linkml_runtime") + from rdflib import Graph + from rdflib.compare import isomorphic + + from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph + + graph = Graph().parse(data=' [ "v" ] .') + canonicalize_rdf_graph(graph) + assert "diffable_rdf" not in sys.modules + output = canonicalize_rdf_graph(graph, output_format="nt", diff_stable=True) + parsed = Graph().parse(data=output, format="nt") + assert len(parsed) == len(graph) + assert isomorphic(graph, parsed) + _assert_diff_stable_labels(output) + _assert_installed("diffable_rdf") + + +def test_linkml_with_extra(tmp_path: Path) -> None: + """The LinkML extra installs the relabeler through the runtime extra.""" + assert find_spec("diffable_rdf") is not None + _assert_installed("linkml_runtime") + _assert_installed("linkml") + _assert_runtime_requirements() + from click.testing import CliRunner + from rdflib import Graph, Namespace + from rdflib.namespace import RDF, SH + + from linkml.generators.shaclgen import cli + + schema = tmp_path / "schema.yaml" + schema.write_text(_SCHEMA, encoding="utf-8") + result = CliRunner().invoke(cli, [str(schema), "--diff-stable"]) + assert result.exit_code == 0, result.output + graph = Graph().parse(data=result.stdout, format="turtle") + ex = Namespace("https://example.org/install/") + assert (ex.Thing, RDF.type, SH.NodeShape) in graph + assert (ex.Thing, SH.targetClass, ex.Thing) in graph + assert {graph.value(node, SH.path) for node in graph.objects(ex.Thing, SH.property)} == {ex.label} + _assert_diff_stable_labels(result.stdout) + _assert_installed("diffable_rdf") diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index a8c122e1bb..dcc4c52ca7 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -237,3 +237,29 @@ jobs: version: ${{ env.UV_VERSION }} - name: Build source and wheel archives run: uv build --all-packages + - name: Test optional diff-stable installations + shell: bash + run: | + shopt -s nullglob + runtime_wheels=(dist/linkml_runtime-*.whl) + linkml_wheels=(dist/linkml-*.whl) + test "${#runtime_wheels[@]}" -eq 1 + test "${#linkml_wheels[@]}" -eq 1 + for scenario in runtime_without_extra linkml_without_extra runtime_with_extra linkml_with_extra; do + scenario_env="$RUNNER_TEMP/$scenario" + uv venv --python 3.13 "$scenario_env" + packages=("${runtime_wheels[0]}" pytest) + case "$scenario" in + runtime_with_extra) packages=("${runtime_wheels[0]}[diff-stable]" pytest) ;; + linkml_without_extra) packages+=("${linkml_wheels[0]}") ;; + linkml_with_extra) packages+=("${linkml_wheels[0]}[diff-stable]") ;; + esac + uv pip install --python "$scenario_env/bin/python" "${packages[@]}" + uv pip check --python "$scenario_env/bin/python" + ( + cd "$RUNNER_TEMP" + uv run --no-project "$scenario_env/bin/python" -I -m pytest \ + --noconftest --import-mode=importlib \ + "$GITHUB_WORKSPACE/.github/tests/test_diff_stable_install.py" -k "$scenario" -v + ) + done diff --git a/docs/howtos/collaborative-development.md b/docs/howtos/collaborative-development.md index 94515df2d1..221337e843 100644 --- a/docs/howtos/collaborative-development.md +++ b/docs/howtos/collaborative-development.md @@ -40,6 +40,36 @@ Some projects may opt to manage their data alongside the schema in the same repo for "registry" style projects, but in other cases the data may be best managed separately in a dedicated store or a repository like Zenodo or Figshare. +(rdf-in-version-control)= +### Keep generated RDF diffs small + +When you track generated RDF in Git, adding a class or property can change many +blank-node labels. The default RDFC-1.0 numbering is repeatable for the same graph, +but an insertion can renumber existing nodes and obscure the actual schema edit. + +The optional `--diff-stable` flag uses +[diffable-rdf](https://github.com/ASCS-eV/diffable-rdf) to reduce this label churn. +In a uv project containing your schema, install LinkML with the extra and generate +SHACL as follows (using a release that includes this option): + +```bash +uv add 'linkml[diff-stable]' +uv run gen-shacl --diff-stable schema.yaml > schema.shacl.ttl +``` + +The flag also works with `gen-owl`, `gen-rdf`, and `gen-shex --format rdf`. +Python callers of `canonicalize_rdf_graph(..., diff_stable=True)` can install +`linkml-runtime[diff-stable]`. The feature is disabled by default; enabling it +changes existing labels once, so keep that regeneration separate from schema edits. + +Labels depend on blank-node neighborhoods. Edits within connected structures or +among symmetric nodes can still affect other labels; minimum diffs are not +guaranteed. Keep tool versions consistent when comparing generated files. +The relabeled output is not the standard RDFC canonical representation. +ShExC, ShEx JSON and instance-data conversion are outside this option's scope. +Non-standard RDF takes the existing fallback path, which warns that it cannot +apply diff-stable labels. + ### Permissively License Your Code and Data (adapted from [O3 guidelines](https://osf.io/vuzt3/)) diff --git a/packages/linkml/pyproject.toml b/packages/linkml/pyproject.toml index 01c524e634..2369602bd4 100644 --- a/packages/linkml/pyproject.toml +++ b/packages/linkml/pyproject.toml @@ -68,6 +68,9 @@ dependencies = [ # Specifier syntax: https://peps.python.org/pep-0631/ "platformdirs>=4.12.1", ] +[project.optional-dependencies] +diff-stable = ["linkml-runtime[diff-stable]>=1.10.0,<2.0.0"] + [dependency-groups] lint = [ "black >= 24.0.0", @@ -79,6 +82,7 @@ shacl = [ "pyshacl>=0.40.1", ] tests = [ + "linkml-runtime[diff-stable]", { include-group = "lint" }, { include-group = "typing" }, { include-group = "shacl" }, diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 09db0357be..3f16c4965c 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -123,20 +123,7 @@ class OwlSchemaGenerator(Generator): # ObjectVars diff_stable: bool = False - """Label blank nodes so that unrelated edits leave them untouched. - - Output is already deterministic: RDFC-1.0 guarantees that isomorphic - graphs serialize identically. It does not guarantee that *similar* - graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a - global order, so adding one class can renumber every blank node after - it and rewrite most of the file. - - When ``True``, blank-node labels are instead derived from each node's - own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels - only the blank nodes it actually touches. The output stays - deterministic and isomorphic either way; only the choice of label - changes. Off by default because enabling it relabels existing output. - """ + """Reduce blank-node label churn. See :ref:`rdf-in-version-control`.""" metadata_profile: MetadataProfile | None = None """Deprecated - use metadata_profiles.""" @@ -1865,9 +1852,8 @@ def slot_owl_type(self, slot: SlotDefinition) -> URIRef: default=False, show_default=True, help=( - "Derive blank-node labels from each node's own neighbourhood so that " - "unrelated edits leave them unchanged. Output is deterministic either " - "way; this makes successive versions of a file diff cleanly." + "Reduce blank-node label churn across edits. See " + "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" ), ) @click.version_option(__version__, "-V", "--version") diff --git a/packages/linkml/src/linkml/generators/rdfgen.py b/packages/linkml/src/linkml/generators/rdfgen.py index 052fcff12e..5b8f8dc1c7 100644 --- a/packages/linkml/src/linkml/generators/rdfgen.py +++ b/packages/linkml/src/linkml/generators/rdfgen.py @@ -79,20 +79,7 @@ class RDFGenerator(Generator): # ObjectVars diff_stable: bool = False - """Label blank nodes so that unrelated edits leave them untouched. - - Output is already deterministic: RDFC-1.0 guarantees that isomorphic - graphs serialize identically. It does not guarantee that *similar* - graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a - global order, so adding one class can renumber every blank node after - it and rewrite most of the file. - - When ``True``, blank-node labels are instead derived from each node's - own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels - only the blank nodes it actually touches. The output stays - deterministic and isomorphic either way; only the choice of label - changes. Off by default because enabling it relabels existing output. - """ + """Reduce blank-node label churn. See :ref:`rdf-in-version-control`.""" emit_metadata: bool = False context: list[str] = None @@ -158,9 +145,8 @@ def end_schema(self, output: str | None = None, context: str = None, **_) -> str default=False, show_default=True, help=( - "Derive blank-node labels from each node's own neighbourhood so that " - "unrelated edits leave them unchanged. Output is deterministic either " - "way; this makes successive versions of a file diff cleanly." + "Reduce blank-node label churn across edits. See " + "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" ), ) @click.version_option(__version__, "-V", "--version") diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 5d42fea272..1804b1204e 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -143,20 +143,7 @@ class ShaclGenerator(Generator): """ diff_stable: bool = False - """Label blank nodes so that unrelated edits leave them untouched. - - Output is already deterministic: RDFC-1.0 guarantees that isomorphic - graphs serialize identically. It does not guarantee that *similar* - graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a - global order, so adding one class can renumber every blank node after - it and rewrite most of the file. - - When ``True``, blank-node labels are instead derived from each node's - own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels - only the blank nodes it actually touches. The output stays - deterministic and isomorphic either way; only the choice of label - changes. Off by default because enabling it relabels existing output. - """ + """Reduce blank-node label churn. See :ref:`rdf-in-version-control`.""" emit_rules: bool = True """Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks. @@ -950,9 +937,8 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None: default=False, show_default=True, help=( - "Derive blank-node labels from each node's own neighbourhood so that " - "unrelated edits leave them unchanged. Output is deterministic either " - "way; this makes successive versions of a file diff cleanly." + "Reduce blank-node label churn across edits. See " + "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" ), ) @click.version_option(__version__, "-V", "--version") diff --git a/packages/linkml/src/linkml/generators/shexgen.py b/packages/linkml/src/linkml/generators/shexgen.py index 7c43b13bc0..e74e45225e 100644 --- a/packages/linkml/src/linkml/generators/shexgen.py +++ b/packages/linkml/src/linkml/generators/shexgen.py @@ -41,20 +41,7 @@ class ShExGenerator(Generator): # ObjectVars diff_stable: bool = False - """Label blank nodes so that unrelated edits leave them untouched. - - Output is already deterministic: RDFC-1.0 guarantees that isomorphic - graphs serialize identically. It does not guarantee that *similar* - graphs serialize *similarly* — blank nodes are numbered ``c14nN`` in a - global order, so adding one class can renumber every blank node after - it and rewrite most of the file. - - When ``True``, blank-node labels are instead derived from each node's - own neighbourhood via Weisfeiler-Lehman refinement, so an edit relabels - only the blank nodes it actually touches. The output stays - deterministic and isomorphic either way; only the choice of label - changes. Off by default because enabling it relabels existing output. - """ + """Reduce blank-node label churn in RDF output. See :ref:`rdf-in-version-control`.""" shex: Schema = field(default_factory=lambda: Schema()) # ShEx Schema being generated shapes: list = field(default_factory=lambda: []) @@ -279,9 +266,8 @@ def _get_subproperty_values(self, slot: SlotDefinition) -> list: default=False, show_default=True, help=( - "Derive blank-node labels from each node's own neighbourhood so that " - "unrelated edits leave them unchanged. Output is deterministic either " - "way; this makes successive versions of a file diff cleanly." + "Reduce blank-node label churn across edits (--format rdf). See " + "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" ), ) @click.version_option(__version__, "-V", "--version") diff --git a/packages/linkml_runtime/pyproject.toml b/packages/linkml_runtime/pyproject.toml index 7509ef60a6..d4c0a2f167 100644 --- a/packages/linkml_runtime/pyproject.toml +++ b/packages/linkml_runtime/pyproject.toml @@ -48,7 +48,6 @@ dependencies = [ "prefixmaps >=0.1.4", "curies>=0.14.6", "pyoxigraph>=0.5.11", - "diffable-rdf>=0.4.0", "pydantic>=2.13.5,<3.0.0", "isodate >=0.7.2, <1.0.0; python_version < '3.11'", ] @@ -63,6 +62,7 @@ dev = [ linkml-normalize = "linkml_runtime.processing.referencevalidator:cli" [project.optional-dependencies] +diff-stable = ["diffable-rdf>=0.4.0"] dev = [ "coverage", "requests-cache>=1.3.3", diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py index 8e28488d55..d7a894be17 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py @@ -39,10 +39,10 @@ import io import re import warnings +from importlib.util import find_spec import pyoxigraph as ox import rdflib -from diffable_rdf import wl_relabel_quads from rdflib.compare import to_canonical_graph @@ -302,15 +302,20 @@ def canonicalize_rdf_graph( :param graph: The rdflib Graph to serialize. :param output_format: Target serialization format (e.g. ``"turtle"``, ``"nt"``). - :param diff_stable: Derive blank-node labels from each node's own - neighbourhood instead of RDFC-1.0's global ``c14nN`` numbering, so that - editing one part of a schema does not renumber unrelated blank nodes. - Output is deterministic and isomorphic either way; only the choice of - label changes. Off by default because enabling it relabels existing - output. Has no effect on the rdflib fallback path (non-standard RDF), - which warns rather than silently ignoring the request. + :param diff_stable: Use diffable-rdf labels to reduce blank-node churn across + edits. Requires the ``diff-stable`` extra; disabled by default. The + non-standard RDF fallback warns when it cannot apply these labels. :return: Deterministic string serialization of the graph. + :raises ImportError: If diff-stable labels are requested without diffable-rdf. """ + if diff_stable: + if find_spec("diffable_rdf") is None: + raise ImportError( + "diff_stable=True requires diffable-rdf. Install " + "'linkml-runtime[diff-stable]' or 'linkml[diff-stable]'." + ) + from diffable_rdf import wl_relabel_quads + ox_format = _FORMAT_MAP.get(output_format.lower()) if ox_format is None: warnings.warn( @@ -340,13 +345,6 @@ def canonicalize_rdf_graph( stacklevel=2, ) if diff_stable: - # Weisfeiler-Lehman refinement consumes canonical pyoxigraph quads, - # and this path exists precisely because pyoxigraph refused the - # graph, so there are none to refine. The fallback is deterministic - # but not diff-stable: say so rather than returning output that - # silently ignores the argument. ``shaclgen --include-annotations`` - # reaches this path, because an annotation tag without a ``:`` - # becomes a literal predicate. warnings.warn( "diff_stable=True was requested but this graph took the rdflib fallback, " "which cannot apply Weisfeiler-Lehman blank-node labels. Output is " @@ -367,15 +365,6 @@ def canonicalize_rdf_graph( quads = list(dataset) - # 3b. Optionally re-label blank nodes with locality-sensitive hashes. - # RDFC-1.0 guarantees that identical graphs get identical labels, but it - # does not guarantee that *similar* graphs get similar labels: the labels - # are assigned by a global ordering, so inserting one blank node can - # renumber every label after it and turn a one-line semantic change into a - # whole-file diff. Weisfeiler-Lehman labels are derived only from each - # node's local neighbourhood, so unrelated regions keep their labels. - # Output stays deterministic and isomorphic either way; this only changes - # which label each blank node receives. if diff_stable: quads = wl_relabel_quads(quads) diff --git a/tests/linkml/test_scripts/test_diff_stable.py b/tests/linkml/test_scripts/test_diff_stable.py new file mode 100644 index 0000000000..cd072aed38 --- /dev/null +++ b/tests/linkml/test_scripts/test_diff_stable.py @@ -0,0 +1,99 @@ +"""CLI integration checks for optional diff-stable RDF labels.""" + +import re +from pathlib import Path + +import click +import pyoxigraph as ox +import pytest +from click.testing import CliRunner +from rdflib import Graph, Namespace, URIRef +from rdflib.namespace import OWL, RDF, SH + +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.owlgen import cli as owl_cli +from linkml.generators.rdfgen import RDFGenerator +from linkml.generators.rdfgen import cli as rdf_cli +from linkml.generators.shaclgen import ShaclGenerator +from linkml.generators.shaclgen import cli as shacl_cli +from linkml.generators.shexgen import ShExGenerator +from linkml.generators.shexgen import cli as shex_cli +from linkml.utils.generator import Generator + + +@pytest.mark.parametrize( + ("command", "args", "rdf_type"), + [ + pytest.param(owl_cli, [], OWL.Class, id="owlgen"), + pytest.param( + rdf_cli, [], URIRef("https://w3id.org/linkml/ClassDefinition"), id="rdfgen", marks=pytest.mark.network + ), + pytest.param(shacl_cli, [], SH.NodeShape, id="shaclgen"), + pytest.param( + shex_cli, + ["--format", "rdf"], + URIRef("http://www.w3.org/ns/shex#Shape"), + id="shexgen", + marks=pytest.mark.network, + ), + ], +) +@pytest.mark.parametrize( + ("flag", "label_pattern"), + [ + pytest.param("--no-diff-stable", r"c14n[0-9]+", id="disabled"), + pytest.param("--diff-stable", r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?", id="enabled"), + ], +) +def test_diff_stable_cli( + tmp_path: Path, command: click.Command, args: list[str], rdf_type: URIRef, flag: str, label_pattern: str +) -> None: + """Every RDF CLI produces the requested labels and the expected class.""" + schema = tmp_path / "schema.yaml" + schema.write_text( + """id: https://example.org/cli +name: cli +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/cli/ +default_prefix: ex +imports: [linkml:types] +classes: + Thing: + attributes: + label: + range: string +""", + encoding="utf-8", + ) + result = CliRunner().invoke(command, [str(schema), flag, *args]) + assert result.exit_code == 0, result.output + quads = list(ox.parse(result.stdout, format=ox.RdfFormat.TURTLE)) + blank_nodes = {term for q in quads for term in (q.subject, q.object) if isinstance(term, ox.BlankNode)} + assert blank_nodes + assert all(re.fullmatch(label_pattern, node.value) for node in blank_nodes) + graph = Graph().parse(data=result.stdout, format="turtle") + assert (Namespace("https://example.org/cli/").Thing, RDF.type, rdf_type) in graph + + +@pytest.mark.parametrize( + ("generator", "command"), + [ + pytest.param(OwlSchemaGenerator, owl_cli, id="owlgen"), + pytest.param(RDFGenerator, rdf_cli, id="rdfgen"), + pytest.param(ShaclGenerator, shacl_cli, id="shaclgen"), + pytest.param(ShExGenerator, shex_cli, id="shexgen"), + ], +) +def test_diff_stable_defaults_and_help(generator: type[Generator], command: click.Command) -> None: + """Defaults are off and terminal help points to the shared guide.""" + assert generator.diff_stable is False + (option,) = [param for param in command.params if param.name == "diff_stable"] + assert option.default is False + result = CliRunner().invoke(command, ["--help"]) + assert result.exit_code == 0 + assert "--diff-stable / --no-diff-stable" in result.output + assert "[default: no-diff-stable]" in result.output + assert "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" in "".join( + result.output.split() + ) diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py index 9fc94d38d4..c3c935a188 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py @@ -1,17 +1,25 @@ """Tests for deterministic RDF serialization via pyoxigraph RDFC-1.0.""" +import difflib +import inspect import os import re import subprocess import sys import textwrap +from pathlib import Path import pyoxigraph as ox import pytest import rdflib -from rdflib import BNode, Graph, Literal, URIRef -from rdflib.namespace import RDF - +from rdflib import BNode, Graph, Literal, Namespace, URIRef +from rdflib.namespace import OWL, RDF, RDFS, SH, XSD + +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.rdfgen import RDFGenerator +from linkml.generators.shaclgen import ShaclGenerator +from linkml.generators.shexgen import ShExGenerator +from linkml.utils.generator import Generator from linkml_runtime.utils import rdf_canonicalize as rdf_canon_mod from linkml_runtime.utils.rdf_canonicalize import ( RDFCanonicalizationWarning, @@ -227,7 +235,8 @@ def test_curie_in_literal_with_hash_not_treated_as_comment(): assert "ex:bar\\. more" in str(obj) -def test_sort_is_load_bearing(): +@pytest.mark.parametrize("diff_stable", [False, pytest.param(True, marks=pytest.mark.diffable_rdf)]) +def test_sort_is_load_bearing(diff_stable: bool) -> None: """Output is byte-identical across subprocesses with different PYTHONHASHSEED values. RDFC-1.0 stabilizes blank-node labels, but pyoxigraph's ``Dataset`` iteration @@ -240,6 +249,8 @@ def test_sort_is_load_bearing(): """ program = textwrap.dedent( """ + import sys + from rdflib import BNode, Graph, Literal, URIRef from rdflib.namespace import RDF from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph @@ -255,7 +266,7 @@ def test_sort_is_load_bearing(): g.add((URIRef("http://example.com/x"), URIRef("http://example.com/has"), bn)) g.add((bn, URIRef("http://example.com/q"), Literal(f"bn_{i}"))) - print(canonicalize_rdf_graph(g, output_format="turtle"), end="") + print(canonicalize_rdf_graph(g, output_format="turtle", diff_stable=sys.argv[1] == "True"), end="") """ ) @@ -264,7 +275,7 @@ def run(seed: str) -> str: # wholesale drops SystemRoot on Windows, which breaks winsock init. env = {**os.environ, "PYTHONHASHSEED": seed} result = subprocess.run( - [sys.executable, "-c", program], + [sys.executable, "-c", program, str(diff_stable)], check=True, capture_output=True, text=True, @@ -523,61 +534,120 @@ def _shapes_graph(count: int, extra: bool = False) -> Graph: def _changed_line_count(before: str, after: str) -> int: - import difflib + """Count added and removed content lines in a unified diff.""" diff = difflib.unified_diff(before.splitlines(), after.splitlines(), n=0, lineterm="") return sum(1 for line in diff if line[:1] in "+-" and not line.startswith(("+++", "---"))) -@pytest.mark.diffable_rdf -def test_diff_stable_is_opt_in(): - """The default must keep producing exactly the output it produced before.""" - graph = _make_graph_with_bnodes() - assert canonicalize_rdf_graph(graph) == canonicalize_rdf_graph(graph, diff_stable=False) +def test_diff_stable_is_opt_in() -> None: + """Diff-stable labels require an explicit request.""" + assert inspect.signature(canonicalize_rdf_graph).parameters["diff_stable"].default is False -@pytest.mark.diffable_rdf -def test_diff_stable_preserves_semantics(): - """Relabelling blank nodes must not change what the graph means.""" +@pytest.mark.parametrize("diff_stable", [False, pytest.param(True, marks=pytest.mark.diffable_rdf)]) +@pytest.mark.parametrize("output_format", ["nt", "turtle"]) +def test_diff_stable_preserves_semantics(diff_stable: bool, output_format: str) -> None: + """Each serialization preserves the source graph under blank-node renaming.""" graph = _make_graph_with_bnodes() - - plain = rdflib.Graph() - plain.parse(data=canonicalize_rdf_graph(graph), format="turtle") - stable = rdflib.Graph() - stable.parse(data=canonicalize_rdf_graph(graph, diff_stable=True), format="turtle") - - assert rdflib.compare.isomorphic(plain, stable) + output = canonicalize_rdf_graph(graph, output_format=output_format, diff_stable=diff_stable) + parsed = Graph().parse(data=output, format=output_format) + assert len(parsed) == len(graph) + assert rdflib.compare.isomorphic(graph, parsed) @pytest.mark.diffable_rdf -def test_diff_stable_is_deterministic(): - """Diff stability must not cost determinism, which is the stronger property.""" - graph = _make_graph_with_bnodes() - outputs = {canonicalize_rdf_graph(graph, diff_stable=True) for _ in range(5)} - assert len(outputs) == 1 +def test_diff_stable_is_deterministic() -> None: + """A cycle's tied signatures remain independent of input blank-node IDs.""" + graphs = [] + predicate = URIRef("http://example.com/next") + for labels, reverse in [("abcd", False), ("dbac", True)]: + nodes = [BNode(label) for label in labels] + triples = [(node, predicate, nodes[(i + 1) % len(nodes)]) for i, node in enumerate(nodes)] + graph = Graph() + for triple in reversed(triples) if reverse else triples: + graph.add(triple) + graphs.append(graph) + + assert rdflib.compare.isomorphic(*graphs) + outputs = [canonicalize_rdf_graph(graph, output_format="nt", diff_stable=True) for graph in graphs] + assert outputs[0] == outputs[1] + for graph, output in zip(graphs, outputs): + assert rdflib.compare.isomorphic(graph, Graph().parse(data=output, format="nt")) + + +@pytest.mark.parametrize("diff_stable", [False, pytest.param(True, marks=pytest.mark.diffable_rdf)]) +def test_diff_stable_insertion_labels(diff_stable: bool) -> None: + """An independent insertion renumbers RDFC labels but preserves the old WL label.""" + before = Graph().parse( + data="""@prefix ex: . + ex:Shape00 ex:property _:old . + _:old ex:path ex:p00 . + """, + format="turtle", + ) + after = Graph() + for triple in before: + after.add(triple) + inserted = BNode() + ex = Namespace("http://example.com/") + after.add((ex.ShapeAAAinserted, ex.property, inserted)) + after.add((inserted, ex.path, ex.pAAAinserted)) + + outputs = [canonicalize_rdf_graph(graph, output_format="nt", diff_stable=diff_stable) for graph in (before, after)] + parsed = [set(ox.parse(output, format=ox.RdfFormat.N_TRIPLES)) for output in outputs] + old_subject = ox.NamedNode(str(ex.Shape00)) + new_subject = ox.NamedNode(str(ex.ShapeAAAinserted)) + property_iri = ox.NamedNode(str(ex.property)) + path_iri = ox.NamedNode(str(ex.path)) + (old_before,) = {q.object for q in parsed[0] if q.subject == old_subject and q.predicate == property_iri} + (old_after,) = {q.object for q in parsed[1] if q.subject == old_subject and q.predicate == property_iri} + (new_after,) = {q.object for q in parsed[1] if q.subject == new_subject and q.predicate == property_iri} + assert all(isinstance(node, ox.BlankNode) for node in (old_before, old_after, new_after)) + assert old_after != new_after + inserted_quads = { + ox.Quad(new_subject, property_iri, new_after), + ox.Quad(new_after, path_iri, ox.NamedNode(str(ex.pAAAinserted))), + } + assert parsed[0] == { + ox.Quad(old_subject, property_iri, old_before), + ox.Quad(old_before, path_iri, ox.NamedNode(str(ex.p00))), + } + assert ( + parsed[1] + == { + ox.Quad(old_subject, property_iri, old_after), + ox.Quad(old_after, path_iri, ox.NamedNode(str(ex.p00))), + } + | inserted_quads + ) + + if diff_stable: + assert all(re.fullmatch(_STABLE_LABEL, node.value) for node in (old_before, old_after, new_after)) + assert old_before == old_after + assert parsed[1] - parsed[0] == inserted_quads + assert parsed[0] - parsed[1] == set() + else: + assert (old_before.value, old_after.value, new_after.value) == ("c14n0", "c14n1", "c14n0") + for graph, output in zip((before, after), outputs): + assert rdflib.compare.isomorphic(graph, Graph().parse(data=output, format="nt")) @pytest.mark.diffable_rdf -def test_diff_stable_confines_an_insertion_to_the_lines_it_touches(): - """Inserting one subject must not relabel the blank nodes of the others. - - RDFC-1.0 numbers blank nodes ``c14nN`` in a global order, so a subject - sorting before the others shifts every subsequent label and rewrites - most of the file. This is the entire reason the option exists, so the - assertion is on the *ratio*, not on an absolute line count that would - be brittle across rdflib versions. - """ +def test_diff_stable_confines_an_insertion_to_the_lines_it_touches() -> None: + """Diff-stable labels reduce Turtle line churn for independent property nodes.""" before, after = _shapes_graph(20), _shapes_graph(20, extra=True) - baseline = _changed_line_count(canonicalize_rdf_graph(before), canonicalize_rdf_graph(after)) stable = _changed_line_count( canonicalize_rdf_graph(before, diff_stable=True), canonicalize_rdf_graph(after, diff_stable=True), ) - assert stable < baseline / 4, f"expected diff-stable output to churn far less; got {stable} vs baseline {baseline}" +_STABLE_LABEL = r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?" + + _DIFF_STABLE_SCHEMA = """\ id: https://example.org/diffstable name: diffstable @@ -602,74 +672,87 @@ def test_diff_stable_confines_an_insertion_to_the_lines_it_touches(): """ -def _generator_cases(): - """The four generators that serialize RDF, with the args that make them do so.""" - from linkml.generators.owlgen import OwlSchemaGenerator - from linkml.generators.rdfgen import RDFGenerator - from linkml.generators.shaclgen import ShaclGenerator - from linkml.generators.shexgen import ShExGenerator +@pytest.fixture +def diff_stable_schema(tmp_path: Path) -> Path: + """Write the small schema for real generator calls.""" + schema = tmp_path / "schema.yaml" + schema.write_text(_DIFF_STABLE_SCHEMA, encoding="utf-8") + return schema - return [ + +@pytest.mark.diffable_rdf +@pytest.mark.parametrize( + ("generator", "kwargs"), + [ pytest.param(OwlSchemaGenerator, {}, id="owlgen"), - # rdfgen and shexgen resolve JSON-LD contexts (linkml types, shex.jsonld); - # the `network` marker serves those from local stubs. See tests/conftest.py. pytest.param(RDFGenerator, {}, id="rdfgen", marks=pytest.mark.network), pytest.param(ShaclGenerator, {}, id="shaclgen"), - # ShExGenerator only emits RDF in this format; its default is ShExC text, - # where blank-node labelling does not apply. pytest.param(ShExGenerator, {"format": "rdf"}, id="shexgen", marks=pytest.mark.network), - ] - - -@pytest.mark.diffable_rdf -@pytest.mark.parametrize(("generator", "kwargs"), _generator_cases()) -def test_diff_stable_reaches_every_rdf_generator(tmp_path, generator, kwargs): - """Every RDF generator must actually apply the option, not merely accept it. - - Asserting only that the attribute exists would pass even if a generator - forgot to pass it down to :func:`canonicalize_rdf_graph`. Instead this - checks the observable consequence: RDFC-1.0 names blank nodes ``c14nN``, - while diff-stable labels are neighbourhood hashes, so a generator that - honours the flag emits no ``c14nN`` label at all. - """ - assert generator.diff_stable is False, f"{generator.__name__} must default to off" - - schema = tmp_path / "schema.yaml" - schema.write_text(_DIFF_STABLE_SCHEMA, encoding="utf-8", newline="\n") - - plain = generator(str(schema), **kwargs).serialize() - stable = generator(str(schema), diff_stable=True, **kwargs).serialize() - - # Guards the test itself: if the fixture stopped producing blank nodes the - # assertion below would hold vacuously. - assert re.search(r"c14n\d+", plain), f"{generator.__name__} output has no blank nodes to relabel" - assert not re.search(r"c14n\d+", stable), ( - f"{generator.__name__} still emits RDFC-1.0 blank-node labels with diff_stable=True; " - "the flag is probably not threaded into canonicalize_rdf_graph()" - ) + ], +) +def test_diff_stable_reaches_every_rdf_generator( + diff_stable_schema: Path, generator: type[Generator], kwargs: dict[str, str] +) -> None: + """Every RDF generator emits the designed schema with diff-stable labels.""" + output = generator(str(diff_stable_schema), diff_stable=True, **kwargs).serialize() + quads = list(ox.parse(output, format=ox.RdfFormat.TURTLE)) + blank_nodes = {term for quad in quads for term in (quad.subject, quad.object) if isinstance(term, ox.BlankNode)} + assert blank_nodes + assert all(re.fullmatch(_STABLE_LABEL, node.value) for node in blank_nodes) + graph = Graph().parse(data=output, format="turtle") + ex = Namespace("https://example.org/diffstable/") + if generator is OwlSchemaGenerator: + assert (ex.Person, RDF.type, OWL.Class) in graph + assert (ex.Organization, RDF.type, OWL.Class) in graph + (restriction,) = { + node + for node in graph.objects(ex.Person, RDFS.subClassOf) + if (node, OWL.onProperty, ex.knows) in graph and (node, OWL.allValuesFrom, ex.Person) in graph + } + assert isinstance(restriction, BNode) + assert (restriction, RDF.type, OWL.Restriction) in graph + assert (restriction, OWL.allValuesFrom, ex.Person) in graph + elif generator is ShaclGenerator: + for subject, paths in [(ex.Person, {ex.name, ex.knows}), (ex.Organization, {ex.name})]: + assert (subject, RDF.type, SH.NodeShape) in graph + assert (subject, SH.targetClass, subject) in graph + properties = set(graph.objects(subject, SH.property)) + assert {graph.value(prop, SH.path) for prop in properties} == paths + (ignored,) = graph.objects(subject, SH.ignoredProperties) + assert set(graph.predicate_objects(ignored)) == {(RDF.first, RDF.type), (RDF.rest, RDF.nil)} + (knows,) = {node for node in graph.objects(ex.Person, SH.property) if (node, SH.path, ex.knows) in graph} + assert (knows, SH["class"], ex.Person) in graph + elif generator is RDFGenerator: + linkml = Namespace("https://w3id.org/linkml/") + schema = ex.diffstable + assert (schema, RDF.type, linkml.SchemaDefinition) in graph + assert set(graph.objects(schema, linkml.classes)) == {ex.Person, ex.Organization} + (prefix,) = {node for node in graph.objects(schema, SH.declare) if (node, SH.prefix, Literal("ex")) in graph} + assert (prefix, SH.namespace, Literal(str(ex), datatype=XSD.anyURI)) in graph + else: + assert generator is ShExGenerator + shex = Namespace("http://www.w3.org/ns/shex#") + assert (ex.Person, RDF.type, shex.Shape) in graph + (expression,) = graph.objects(ex.Person, shex.expression) + assert (expression, RDF.type, shex.EachOf) in graph + (expressions,) = graph.objects(expression, shex.expressions) + assert ex.Person_tes in set(graph.items(expressions)) + assert (ex.Person_tes, RDF.type, shex.EachOf) in graph + (slots,) = graph.objects(ex.Person_tes, shex.expressions) + (knows,) = {node for node in graph.items(slots) if (node, shex.predicate, ex.knows) in graph} + assert (knows, RDF.type, shex.TripleConstraint) in graph + assert (knows, shex.valueExpr, ex.Person) in graph + assert (knows, shex.min, Literal(0)) in graph + assert (knows, shex.max, Literal(-1)) in graph @pytest.mark.diffable_rdf -def test_diff_stable_warns_instead_of_silently_no_opping_on_the_fallback(): - """A request the fallback cannot honour must be reported, not ignored. - - ``wl_relabel_quads`` consumes canonical pyoxigraph quads, and the rdflib - fallback exists precisely because pyoxigraph refused the graph. Returning - the same bytes for ``diff_stable=True`` and ``diff_stable=False`` without - saying so lets a caller believe the output is diff-stable when it is not. - """ +def test_diff_stable_warns_instead_of_silently_no_opping_on_the_fallback() -> None: + """A relative IRI requires the fallback and a warning that labels cannot apply.""" graph = _make_graph_with_bnodes() - # A relative IRI is non-standard RDF, so pyoxigraph rejects the graph and - # canonicalize_rdf_graph degrades to rdflib -- the same path that - # ``shaclgen --include-annotations`` takes via its literal predicates. graph.add((URIRef("testing"), URIRef("http://example.com/p"), Literal("v"))) - with pytest.warns(RDFCanonicalizationWarning, match="NOT diff-stable"): stable = canonicalize_rdf_graph(graph, diff_stable=True) - with pytest.warns(RDFCanonicalizationWarning): plain = canonicalize_rdf_graph(graph, diff_stable=False) - - # The warning is the contract: the bytes really are identical, which is - # exactly why staying silent would be misleading. assert stable == plain diff --git a/uv.lock b/uv.lock index a313d1e298..4b9436e0e6 100644 --- a/uv.lock +++ b/uv.lock @@ -652,7 +652,7 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130, upload-time = "2025-04-15T17:47:53.79Z" } wheels = [ @@ -725,7 +725,7 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" } }, + { name = "numpy", version = "2.3.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } wheels = [ @@ -1176,7 +1176,7 @@ name = "exceptiongroup" version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" } wheels = [ @@ -1719,17 +1719,17 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "exceptiongroup" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version < '3.11'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "jedi", marker = "python_full_version < '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version < '3.11'" }, + { name = "pexpect", marker = "python_full_version < '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "stack-data", marker = "python_full_version < '3.11'" }, + { name = "traitlets", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/85/31/10ac88f3357fc276dc8a64e8880c82e80e7459326ae1d0a211b40abf6665/ipython-8.37.0.tar.gz", hash = "sha256:ca815841e1a41a1e6b73a0b08f3038af9b2252564d01fc405356d34033012216", size = 5606088, upload-time = "2025-05-31T16:39:09.613Z" } wheels = [ @@ -1747,17 +1747,17 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "decorator" }, - { name = "ipython-pygments-lexers" }, - { name = "jedi" }, - { name = "matplotlib-inline" }, - { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit" }, - { name = "pygments" }, - { name = "stack-data" }, - { name = "traitlets" }, - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { name = "matplotlib-inline", marker = "python_full_version >= '3.11'" }, + { name = "pexpect", marker = "python_full_version >= '3.11' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "prompt-toolkit", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "stack-data", marker = "python_full_version >= '3.11'" }, + { name = "traitlets", marker = "python_full_version >= '3.11'" }, + { name = "typing-extensions", marker = "python_full_version == '3.11.*'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/2a/34/29b18c62e39ee2f7a6a3bba7efd952729d8aadd45ca17efc34453b717665/ipython-9.6.0.tar.gz", hash = "sha256:5603d6d5d356378be5043e69441a072b50a5b33b4503428c77b04cb8ce7bc731", size = 4396932, upload-time = "2025-09-29T10:55:53.948Z" } wheels = [ @@ -1778,7 +1778,7 @@ name = "ipython-pygments-lexers" version = "1.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "pygments" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/ef/4c/5dd1d8af08107f88c7f741ead7a40854b8ac24ddf9ae850afbcf698aa552/ipython_pygments_lexers-1.1.1.tar.gz", hash = "sha256:09c0138009e56b6854f9535736f4171d855c8c08a563a0dcd8022f78355c7e81", size = 8393, upload-time = "2025-01-17T11:24:34.505Z" } wheels = [ @@ -2408,6 +2408,11 @@ dependencies = [ { name = "watchdog" }, ] +[package.optional-dependencies] +diff-stable = [ + { name = "linkml-runtime", extra = ["diff-stable"] }, +] + [package.dev-dependencies] bigquery = [ { name = "sqlalchemy-bigquery" }, @@ -2422,6 +2427,7 @@ dev = [ { name = "ipython-genutils" }, { name = "jsonpatch" }, { name = "jupyter" }, + { name = "linkml-runtime", extra = ["diff-stable"] }, { name = "mock" }, { name = "myst-nb" }, { name = "nbconvert" }, @@ -2474,6 +2480,7 @@ shacl = [ tests = [ { name = "black" }, { name = "duckdb" }, + { name = "linkml-runtime", extra = ["diff-stable"] }, { name = "numpydantic" }, { name = "pyshacl" }, { name = "sqlalchemy-bigquery" }, @@ -2513,6 +2520,7 @@ requires-dist = [ { name = "jsonasobj2", specifier = ">=1.0.3,<2.0.0" }, { name = "jsonschema", extras = ["format"], specifier = ">=4.26.0" }, { name = "linkml-runtime", editable = "packages/linkml_runtime" }, + { name = "linkml-runtime", extras = ["diff-stable"], marker = "extra == 'diff-stable'", editable = "packages/linkml_runtime" }, { name = "openapi-spec-validator", specifier = ">=0.8.4" }, { name = "openpyxl" }, { name = "parse" }, @@ -2533,6 +2541,7 @@ requires-dist = [ { name = "typing-extensions", marker = "python_full_version < '3.12'", specifier = ">=4.6.0" }, { name = "watchdog", specifier = ">=0.9.0" }, ] +provides-extras = ["diff-stable"] [package.metadata.requires-dev] bigquery = [{ name = "sqlalchemy-bigquery", specifier = ">=1.9.0" }] @@ -2546,6 +2555,7 @@ dev = [ { name = "ipython-genutils" }, { name = "jsonpatch", specifier = ">=1.33" }, { name = "jupyter" }, + { name = "linkml-runtime", extras = ["diff-stable"], editable = "packages/linkml_runtime" }, { name = "mock", specifier = ">=5.1.0" }, { name = "myst-nb", marker = "python_full_version >= '3.10'", specifier = ">=1.4.0" }, { name = "nbconvert" }, @@ -2593,6 +2603,7 @@ shacl = [{ name = "pyshacl", specifier = ">=0.40.1" }] tests = [ { name = "black", specifier = ">=24.0.0" }, { name = "duckdb", specifier = ">=1.5.5" }, + { name = "linkml-runtime", extras = ["diff-stable"], editable = "packages/linkml_runtime" }, { name = "numpydantic", specifier = ">=1.10.0" }, { name = "pyshacl", specifier = ">=0.40.1" }, { name = "sqlalchemy-bigquery", specifier = ">=1.17.2" }, @@ -2622,7 +2633,6 @@ dependencies = [ { name = "click" }, { name = "curies" }, { name = "deprecated" }, - { name = "diffable-rdf" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'" }, { name = "json-flattener" }, @@ -2642,6 +2652,9 @@ dev = [ { name = "coverage" }, { name = "requests-cache" }, ] +diff-stable = [ + { name = "diffable-rdf" }, +] [package.dev-dependencies] dev = [ @@ -2655,7 +2668,7 @@ requires-dist = [ { name = "coverage", marker = "extra == 'dev'" }, { name = "curies", specifier = ">=0.14.6" }, { name = "deprecated" }, - { name = "diffable-rdf", specifier = ">=0.4.0" }, + { name = "diffable-rdf", marker = "extra == 'diff-stable'", specifier = ">=0.4.0" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'", specifier = ">=0.7.2,<1.0.0" }, { name = "json-flattener", specifier = ">=0.1.9" }, @@ -2670,7 +2683,7 @@ requires-dist = [ { name = "requests" }, { name = "requests-cache", marker = "extra == 'dev'", specifier = ">=1.3.3" }, ] -provides-extras = ["dev"] +provides-extras = ["dev", "diff-stable"] [package.metadata.requires-dev] dev = [ @@ -4971,23 +4984,23 @@ resolution-markers = [ "python_full_version < '3.11'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, - { name = "tomli", version = "2.4.1", source = { registry = "https://pypi.org/simple" } }, + { name = "alabaster", marker = "python_full_version < '3.11'" }, + { name = "babel", marker = "python_full_version < '3.11'" }, + { name = "colorama", marker = "python_full_version < '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version < '3.11'" }, + { name = "imagesize", marker = "python_full_version < '3.11'" }, + { name = "jinja2", marker = "python_full_version < '3.11'" }, + { name = "packaging", marker = "python_full_version < '3.11'" }, + { name = "pygments", marker = "python_full_version < '3.11'" }, + { name = "requests", marker = "python_full_version < '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version < '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version < '3.11'" }, + { name = "tomli", version = "2.4.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/be0b61178fe2cdcb67e2a92fc9ebb488e3c51c4f74a36a7824c0adf23425/sphinx-8.1.3.tar.gz", hash = "sha256:43c1911eecb0d3e161ad78611bc905d1ad0e523e4ddc202a58a821773dc4c927", size = 8184611, upload-time = "2024-10-13T20:27:13.93Z" } wheels = [ @@ -5005,23 +5018,23 @@ resolution-markers = [ "python_full_version == '3.11.*'", ] dependencies = [ - { name = "alabaster" }, - { name = "babel" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "docutils" }, - { name = "imagesize" }, - { name = "jinja2" }, - { name = "packaging" }, - { name = "pygments" }, - { name = "requests" }, - { name = "roman-numerals-py" }, - { name = "snowballstemmer" }, - { name = "sphinxcontrib-applehelp" }, - { name = "sphinxcontrib-devhelp" }, - { name = "sphinxcontrib-htmlhelp" }, - { name = "sphinxcontrib-jsmath" }, - { name = "sphinxcontrib-qthelp" }, - { name = "sphinxcontrib-serializinghtml" }, + { name = "alabaster", marker = "python_full_version >= '3.11'" }, + { name = "babel", marker = "python_full_version >= '3.11'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "docutils", marker = "python_full_version >= '3.11'" }, + { name = "imagesize", marker = "python_full_version >= '3.11'" }, + { name = "jinja2", marker = "python_full_version >= '3.11'" }, + { name = "packaging", marker = "python_full_version >= '3.11'" }, + { name = "pygments", marker = "python_full_version >= '3.11'" }, + { name = "requests", marker = "python_full_version >= '3.11'" }, + { name = "roman-numerals-py", marker = "python_full_version >= '3.11'" }, + { name = "snowballstemmer", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-applehelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-devhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-htmlhelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-jsmath", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-qthelp", marker = "python_full_version >= '3.11'" }, + { name = "sphinxcontrib-serializinghtml", marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/ad/4360e50ed56cb483667b8e6dadf2d3fda62359593faabbe749a27c4eaca6/sphinx-8.2.3.tar.gz", hash = "sha256:398ad29dee7f63a75888314e9424d40f52ce5a6a87ae88e7071e80af296ec348", size = 8321876, upload-time = "2025-03-02T22:31:59.658Z" } wheels = [ From 0800a0071f39d8f5acbfbac6791dd8b6637d7e5c Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 2 Oct 2026 12:03:48 +0200 Subject: [PATCH 12/30] test(rdf): trim the diff-stable tests and reuse the existing fixture Addresses the second review round. Remove the installed-wheel checks and the CI step that drove them. They were a one-off apparatus for something the test suite already has a mechanism for, and part of it only asserted that uv builds wheel metadata correctly from pyproject.toml rather than testing anything about linkml. Test the missing extra with a new mock_missing_import fixture built on the existing MockImportErrorFinder, so the next optional dependency needs no fixture of its own. Guard the import the way bigquerygen already does: find_spec raises under that fixture, so the install hint never surfaced. Drop the default-value test, restore the pre-existing sort test, and cut the CLI matrix to the single case that shows the flag reaching the serializer. Record the convention in AGENTS.md, point MockImportErrorFinder at the general fixture, and link the guide from the diff_stable parameter. --- .github/tests/test_diff_stable_install.py | 147 ------------------ .github/workflows/main.yaml | 26 ---- AGENTS.md | 2 + .../linkml_runtime/utils/rdf_canonicalize.py | 9 +- tests/conftest.py | 37 ++++- tests/linkml/test_scripts/test_diff_stable.py | 90 +++-------- .../test_utils/test_rdf_canonicalize.py | 19 ++- 7 files changed, 70 insertions(+), 260 deletions(-) delete mode 100644 .github/tests/test_diff_stable_install.py diff --git a/.github/tests/test_diff_stable_install.py b/.github/tests/test_diff_stable_install.py deleted file mode 100644 index ef2927ccf4..0000000000 --- a/.github/tests/test_diff_stable_install.py +++ /dev/null @@ -1,147 +0,0 @@ -"""Run each selection in a fresh environment containing the built wheels.""" - -import importlib -import re -import sys -import sysconfig -from importlib.metadata import metadata, version -from importlib.util import find_spec -from pathlib import Path - -import pytest -from packaging.requirements import Requirement - -_SCHEMA = """id: https://example.org/install -name: install -prefixes: - linkml: https://w3id.org/linkml/ - ex: https://example.org/install/ -default_prefix: ex -imports: [linkml:types] -classes: - Thing: - attributes: - label: - range: string -""" - - -def _assert_installed(package: str) -> None: - """Require imports from this environment's installed package directory.""" - module = importlib.import_module(package) - assert Path(module.__file__).resolve().is_relative_to(Path(sysconfig.get_path("purelib")).resolve()) - - -def _assert_runtime_requirements() -> None: - """Check ordinary compatibility and the extra's forwarding in wheel metadata.""" - requirements = [Requirement(value) for value in metadata("linkml").get_all("Requires-Dist")] - (ordinary,) = [r for r in requirements if r.name == "linkml-runtime" and r.marker is None] - (forwarded,) = [r for r in requirements if r.name == "linkml-runtime" and r.marker is not None] - assert ordinary.extras == set() - assert forwarded.extras == {"diff-stable"} - assert forwarded.marker.evaluate({"extra": "diff-stable"}) - assert not forwarded.marker.evaluate({"extra": ""}) - assert ordinary.specifier == forwarded.specifier - assert ordinary.specifier.contains(version("linkml-runtime"), prereleases=True) - - -def _assert_diff_stable_labels(output: str) -> None: - """Require actual blank-node terms with the enabled label syntax.""" - import pyoxigraph as ox - - quads = ox.parse(output, format=ox.RdfFormat.TURTLE) - nodes = {term for q in quads for term in (q.subject, q.object) if isinstance(term, ox.BlankNode)} - assert nodes - assert all(re.fullmatch(r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?", node.value) for node in nodes) - - -def test_runtime_without_extra() -> None: - """Ordinary runtime installs work and explain how to enable the option.""" - assert find_spec("diffable_rdf") is None - _assert_installed("linkml_runtime") - from rdflib import Graph - from rdflib.compare import isomorphic - - from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph - - graph = Graph().parse(data=' [ "v" ] .') - for kwargs in ({}, {"diff_stable": False}): - output = canonicalize_rdf_graph(graph, **kwargs) - assert isomorphic(graph, Graph().parse(data=output, format="turtle")) - assert "diffable_rdf" not in sys.modules - with pytest.raises(ImportError, match=r"linkml-runtime\[diff-stable\]"): - canonicalize_rdf_graph(graph, diff_stable=True) - - -def test_linkml_without_extra(tmp_path: Path) -> None: - """Generator imports, help and default output do not require the extra.""" - assert find_spec("diffable_rdf") is None - _assert_installed("linkml_runtime") - _assert_installed("linkml") - _assert_runtime_requirements() - from click.testing import CliRunner - from rdflib import Graph - from rdflib.namespace import RDF, SH - - from linkml.generators.shaclgen import cli - - for name in ("owlgen", "rdfgen", "shaclgen", "shexgen"): - command = importlib.import_module(f"linkml.generators.{name}").cli - result = CliRunner().invoke(command, ["--help"]) - assert result.exit_code == 0, result.output - schema = tmp_path / "schema.yaml" - schema.write_text(_SCHEMA, encoding="utf-8") - result = CliRunner().invoke(cli, [str(schema)]) - assert result.exit_code == 0, result.output - graph = Graph().parse(data=result.stdout, format="turtle") - assert list(graph.subjects(RDF.type, SH.NodeShape)) - assert "diffable_rdf" not in sys.modules - result = CliRunner().invoke(cli, [str(schema), "--diff-stable"]) - assert result.exit_code != 0 - assert isinstance(result.exception, ImportError) - assert "linkml[diff-stable]" in str(result.exception) - - -def test_runtime_with_extra() -> None: - """The runtime extra supplies the relabeler and preserves the source graph.""" - assert find_spec("diffable_rdf") is not None - _assert_installed("linkml_runtime") - from rdflib import Graph - from rdflib.compare import isomorphic - - from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph - - graph = Graph().parse(data=' [ "v" ] .') - canonicalize_rdf_graph(graph) - assert "diffable_rdf" not in sys.modules - output = canonicalize_rdf_graph(graph, output_format="nt", diff_stable=True) - parsed = Graph().parse(data=output, format="nt") - assert len(parsed) == len(graph) - assert isomorphic(graph, parsed) - _assert_diff_stable_labels(output) - _assert_installed("diffable_rdf") - - -def test_linkml_with_extra(tmp_path: Path) -> None: - """The LinkML extra installs the relabeler through the runtime extra.""" - assert find_spec("diffable_rdf") is not None - _assert_installed("linkml_runtime") - _assert_installed("linkml") - _assert_runtime_requirements() - from click.testing import CliRunner - from rdflib import Graph, Namespace - from rdflib.namespace import RDF, SH - - from linkml.generators.shaclgen import cli - - schema = tmp_path / "schema.yaml" - schema.write_text(_SCHEMA, encoding="utf-8") - result = CliRunner().invoke(cli, [str(schema), "--diff-stable"]) - assert result.exit_code == 0, result.output - graph = Graph().parse(data=result.stdout, format="turtle") - ex = Namespace("https://example.org/install/") - assert (ex.Thing, RDF.type, SH.NodeShape) in graph - assert (ex.Thing, SH.targetClass, ex.Thing) in graph - assert {graph.value(node, SH.path) for node in graph.objects(ex.Thing, SH.property)} == {ex.label} - _assert_diff_stable_labels(result.stdout) - _assert_installed("diffable_rdf") diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml index dcc4c52ca7..a8c122e1bb 100644 --- a/.github/workflows/main.yaml +++ b/.github/workflows/main.yaml @@ -237,29 +237,3 @@ jobs: version: ${{ env.UV_VERSION }} - name: Build source and wheel archives run: uv build --all-packages - - name: Test optional diff-stable installations - shell: bash - run: | - shopt -s nullglob - runtime_wheels=(dist/linkml_runtime-*.whl) - linkml_wheels=(dist/linkml-*.whl) - test "${#runtime_wheels[@]}" -eq 1 - test "${#linkml_wheels[@]}" -eq 1 - for scenario in runtime_without_extra linkml_without_extra runtime_with_extra linkml_with_extra; do - scenario_env="$RUNNER_TEMP/$scenario" - uv venv --python 3.13 "$scenario_env" - packages=("${runtime_wheels[0]}" pytest) - case "$scenario" in - runtime_with_extra) packages=("${runtime_wheels[0]}[diff-stable]" pytest) ;; - linkml_without_extra) packages+=("${linkml_wheels[0]}") ;; - linkml_with_extra) packages+=("${linkml_wheels[0]}[diff-stable]") ;; - esac - uv pip install --python "$scenario_env/bin/python" "${packages[@]}" - uv pip check --python "$scenario_env/bin/python" - ( - cd "$RUNNER_TEMP" - uv run --no-project "$scenario_env/bin/python" -I -m pytest \ - --noconftest --import-mode=importlib \ - "$GITHUB_WORKSPACE/.github/tests/test_diff_stable_install.py" -k "$scenario" -v - ) - done diff --git a/AGENTS.md b/AGENTS.md index 772f7c59ab..c9a178e6c2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,6 +23,8 @@ All commands use `uv run` prefix (e.g., `uv run pytest`). * Do not "fix" issues by changing or weakening test conditions. Try harder, or ask questions if a test fails. * Avoid try/except blocks, these can mask bugs * Failing fast is a good principle +* Optional dependencies are the documented exception to the two rules above. Guard the import, not the spec: `try: import x` / `except ImportError as exc: raise ImportError("x is required. Install with: pip install 'linkml[extra]'") from exc` - see `generators/bigquerygen.py`. `importlib.util.find_spec` raises under the test fixture below, which hides your message. +* Testing that an optional dependency is absent is not a mock test: use the `mock_missing_import` fixture in `tests/conftest.py`. Do not build isolated environments in CI for this. * Follow the DRY principle * Avoid repeating chunks of code, but also avoid premature over-abstraction * Declarative principles are favored diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py index d7a894be17..bc06a969b8 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py @@ -39,7 +39,6 @@ import io import re import warnings -from importlib.util import find_spec import pyoxigraph as ox import rdflib @@ -305,16 +304,18 @@ def canonicalize_rdf_graph( :param diff_stable: Use diffable-rdf labels to reduce blank-node churn across edits. Requires the ``diff-stable`` extra; disabled by default. The non-standard RDF fallback warns when it cannot apply these labels. + See https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control :return: Deterministic string serialization of the graph. :raises ImportError: If diff-stable labels are requested without diffable-rdf. """ if diff_stable: - if find_spec("diffable_rdf") is None: + try: + from diffable_rdf import wl_relabel_quads + except ImportError as exc: raise ImportError( "diff_stable=True requires diffable-rdf. Install " "'linkml-runtime[diff-stable]' or 'linkml[diff-stable]'." - ) - from diffable_rdf import wl_relabel_quads + ) from exc ox_format = _FORMAT_MAP.get(output_format.lower()) if ox_format is None: diff --git a/tests/conftest.py b/tests/conftest.py index bcf958b9e5..ba13d6f764 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -422,7 +422,8 @@ class MockImportErrorFinder(MetaPathFinder): """ Fake like we don't have a module when we really do. - see the ``mock_black_import`` fixture for example usage. + see the ``mock_missing_import`` fixture to hide any module, or ``mock_black_import`` + for a single-purpose example. .. note:: @@ -442,6 +443,40 @@ def find_spec(self, fullname, path, target): return None +@pytest.fixture(scope="function") +def mock_missing_import(): + """Pretend an optional dependency is not installed, for any module. + + Yields a callable taking a module name, so a test for one optional + dependency does not need a fixture of its own:: + + def test_requires_the_extra(mock_missing_import): + mock_missing_import("diffable_rdf") + with pytest.raises(ImportError, match="linkml-runtime[diff-stable]"): + ... + + See :class:`MockImportErrorFinder` for the caveat about reimporting + modules that already imported the one being hidden. + """ + removed = {} + finders = [] + + def hide(module: str) -> None: + for name, loaded in list(sys.modules.items()): + if name.startswith(module): + removed[name] = loaded + del sys.modules[name] + finder = MockImportErrorFinder(module) + finders.append(finder) + sys.meta_path.insert(0, finder) + + yield hide + + sys.modules.update(removed) + for finder in finders: + sys.meta_path.remove(finder) + + @pytest.fixture(scope="function") def mock_black_import(): """ diff --git a/tests/linkml/test_scripts/test_diff_stable.py b/tests/linkml/test_scripts/test_diff_stable.py index cd072aed38..d1c292276b 100644 --- a/tests/linkml/test_scripts/test_diff_stable.py +++ b/tests/linkml/test_scripts/test_diff_stable.py @@ -1,57 +1,15 @@ -"""CLI integration checks for optional diff-stable RDF labels.""" +"""CLI checks for the optional diff-stable RDF labels.""" import re from pathlib import Path -import click import pyoxigraph as ox import pytest from click.testing import CliRunner -from rdflib import Graph, Namespace, URIRef -from rdflib.namespace import OWL, RDF, SH -from linkml.generators.owlgen import OwlSchemaGenerator -from linkml.generators.owlgen import cli as owl_cli -from linkml.generators.rdfgen import RDFGenerator -from linkml.generators.rdfgen import cli as rdf_cli -from linkml.generators.shaclgen import ShaclGenerator from linkml.generators.shaclgen import cli as shacl_cli -from linkml.generators.shexgen import ShExGenerator -from linkml.generators.shexgen import cli as shex_cli -from linkml.utils.generator import Generator - -@pytest.mark.parametrize( - ("command", "args", "rdf_type"), - [ - pytest.param(owl_cli, [], OWL.Class, id="owlgen"), - pytest.param( - rdf_cli, [], URIRef("https://w3id.org/linkml/ClassDefinition"), id="rdfgen", marks=pytest.mark.network - ), - pytest.param(shacl_cli, [], SH.NodeShape, id="shaclgen"), - pytest.param( - shex_cli, - ["--format", "rdf"], - URIRef("http://www.w3.org/ns/shex#Shape"), - id="shexgen", - marks=pytest.mark.network, - ), - ], -) -@pytest.mark.parametrize( - ("flag", "label_pattern"), - [ - pytest.param("--no-diff-stable", r"c14n[0-9]+", id="disabled"), - pytest.param("--diff-stable", r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?", id="enabled"), - ], -) -def test_diff_stable_cli( - tmp_path: Path, command: click.Command, args: list[str], rdf_type: URIRef, flag: str, label_pattern: str -) -> None: - """Every RDF CLI produces the requested labels and the expected class.""" - schema = tmp_path / "schema.yaml" - schema.write_text( - """id: https://example.org/cli +_SCHEMA = """id: https://example.org/cli name: cli prefixes: linkml: https://w3id.org/linkml/ @@ -63,37 +21,25 @@ def test_diff_stable_cli( attributes: label: range: string -""", - encoding="utf-8", - ) - result = CliRunner().invoke(command, [str(schema), flag, *args]) - assert result.exit_code == 0, result.output - quads = list(ox.parse(result.stdout, format=ox.RdfFormat.TURTLE)) - blank_nodes = {term for q in quads for term in (q.subject, q.object) if isinstance(term, ox.BlankNode)} - assert blank_nodes - assert all(re.fullmatch(label_pattern, node.value) for node in blank_nodes) - graph = Graph().parse(data=result.stdout, format="turtle") - assert (Namespace("https://example.org/cli/").Thing, RDF.type, rdf_type) in graph +""" @pytest.mark.parametrize( - ("generator", "command"), + ("flag", "label_pattern"), [ - pytest.param(OwlSchemaGenerator, owl_cli, id="owlgen"), - pytest.param(RDFGenerator, rdf_cli, id="rdfgen"), - pytest.param(ShaclGenerator, shacl_cli, id="shaclgen"), - pytest.param(ShExGenerator, shex_cli, id="shexgen"), + pytest.param("--no-diff-stable", r"c14n[0-9]+", id="disabled"), + pytest.param("--diff-stable", r"b[0-9a-f]{12}(?:_[1-9][0-9]*)?", id="enabled", marks=pytest.mark.diffable_rdf), ], ) -def test_diff_stable_defaults_and_help(generator: type[Generator], command: click.Command) -> None: - """Defaults are off and terminal help points to the shared guide.""" - assert generator.diff_stable is False - (option,) = [param for param in command.params if param.name == "diff_stable"] - assert option.default is False - result = CliRunner().invoke(command, ["--help"]) - assert result.exit_code == 0 - assert "--diff-stable / --no-diff-stable" in result.output - assert "[default: no-diff-stable]" in result.output - assert "https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control" in "".join( - result.output.split() - ) +def test_the_flag_reaches_the_serializer(tmp_path: Path, flag: str, label_pattern: str) -> None: + """The CLI flag selects the blank-node labelling it names.""" + schema = tmp_path / "schema.yaml" + schema.write_text(_SCHEMA, encoding="utf-8") + + result = CliRunner().invoke(shacl_cli, [str(schema), flag]) + + assert result.exit_code == 0, result.output + quads = list(ox.parse(result.stdout, format=ox.RdfFormat.TURTLE)) + blank_nodes = {term for q in quads for term in (q.subject, q.object) if isinstance(term, ox.BlankNode)} + assert blank_nodes + assert all(re.fullmatch(label_pattern, node.value) for node in blank_nodes) diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py index c3c935a188..8da48fa8b3 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py @@ -1,7 +1,6 @@ """Tests for deterministic RDF serialization via pyoxigraph RDFC-1.0.""" import difflib -import inspect import os import re import subprocess @@ -235,8 +234,7 @@ def test_curie_in_literal_with_hash_not_treated_as_comment(): assert "ex:bar\\. more" in str(obj) -@pytest.mark.parametrize("diff_stable", [False, pytest.param(True, marks=pytest.mark.diffable_rdf)]) -def test_sort_is_load_bearing(diff_stable: bool) -> None: +def test_sort_is_load_bearing(): """Output is byte-identical across subprocesses with different PYTHONHASHSEED values. RDFC-1.0 stabilizes blank-node labels, but pyoxigraph's ``Dataset`` iteration @@ -249,8 +247,6 @@ def test_sort_is_load_bearing(diff_stable: bool) -> None: """ program = textwrap.dedent( """ - import sys - from rdflib import BNode, Graph, Literal, URIRef from rdflib.namespace import RDF from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph @@ -266,7 +262,7 @@ def test_sort_is_load_bearing(diff_stable: bool) -> None: g.add((URIRef("http://example.com/x"), URIRef("http://example.com/has"), bn)) g.add((bn, URIRef("http://example.com/q"), Literal(f"bn_{i}"))) - print(canonicalize_rdf_graph(g, output_format="turtle", diff_stable=sys.argv[1] == "True"), end="") + print(canonicalize_rdf_graph(g, output_format="turtle"), end="") """ ) @@ -275,7 +271,7 @@ def run(seed: str) -> str: # wholesale drops SystemRoot on Windows, which breaks winsock init. env = {**os.environ, "PYTHONHASHSEED": seed} result = subprocess.run( - [sys.executable, "-c", program, str(diff_stable)], + [sys.executable, "-c", program], check=True, capture_output=True, text=True, @@ -540,9 +536,12 @@ def _changed_line_count(before: str, after: str) -> int: return sum(1 for line in diff if line[:1] in "+-" and not line.startswith(("+++", "---"))) -def test_diff_stable_is_opt_in() -> None: - """Diff-stable labels require an explicit request.""" - assert inspect.signature(canonicalize_rdf_graph).parameters["diff_stable"].default is False +def test_diff_stable_without_the_extra_names_the_extra(mock_missing_import) -> None: + """Requesting the opt-in path without diffable-rdf says how to install it.""" + mock_missing_import("diffable_rdf") + + with pytest.raises(ImportError, match=r"linkml-runtime\[diff-stable\]"): + canonicalize_rdf_graph(_make_graph_with_bnodes(), diff_stable=True) @pytest.mark.parametrize("diff_stable", [False, pytest.param(True, marks=pytest.mark.diffable_rdf)]) From 35f1df80b840c475d94c7a9d5fd66a801b448cae Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 11 Sep 2026 16:34:30 +0200 Subject: [PATCH 13/30] docs(owl): document deterministic serialization and --diff-stable gen-owl canonicalizes its output with RDFC-1.0 before serializing, and the determinism work adds a --diff-stable option on top of it, but neither is mentioned anywhere in the generator documentation. Describe what is guaranteed without passing any flag, why RDFC-1.0's sequential blank-node numbering can still produce noisy diffs across schema edits, and what --diff-stable changes. Also record the behaviour users meet in practice but cannot discover from --help: that the same option exists on gen-rdf, gen-shacl and gen-shex, and that graphs which are not standard RDF -- literal predicates from SHACL annotation mode, relative IRIs such as the metamodel's bibo:status -- take an rdflib fallback that stays reproducible across processes, warns, and deliberately does not honour --diff-stable. --- docs/generators/owl.rst | 42 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst index 4b6f076fe8..87b0f402c4 100644 --- a/docs/generators/owl.rst +++ b/docs/generators/owl.rst @@ -311,6 +311,48 @@ Other examples translation of Biolink schema to OWL +Deterministic output +^^^^^^^^^^^^^^^^^^^^ + +``gen-owl`` output is deterministic by default. The graph is canonicalized with +`RDFC-1.0 `_ before serialization, so repeated +runs over the same schema -- and any two isomorphic graphs -- produce +byte-identical Turtle. No flag is needed, and checked-in artifacts do not churn +between runs. + +RDFC-1.0 numbers blank nodes sequentially (``_:c14n0``, ``_:c14n1``, ...) in +canonical order. That is stable for a fixed graph, but inserting a single +statement can shift the numbering of every blank node ordered after it, so an +unrelated one-line schema edit may rewrite large parts of the file. Pass +``--diff-stable`` to derive each label from the node's own neighbourhood +instead, so that only the blank nodes an edit actually touches are renamed: + +.. code:: bash + + gen-owl --diff-stable schema.yaml + +Both modes are deterministic and yield isomorphic graphs; only the choice of +label differs. ``--diff-stable`` is off by default because turning it on +relabels the blank nodes in existing output once. + +The same ``--diff-stable/--no-diff-stable`` option is available on ``gen-rdf``, +``gen-shacl`` and ``gen-shex``. + +Graphs that are not standard RDF -- literal predicates, as produced by +``gen-shacl`` in annotation mode, or relative IRIs such as the metamodel's +``bibo:status `` -- cannot be canonicalized under RDFC-1.0. Those fall +back to plain rdflib serialization, with blank-node labels canonicalized by +``rdflib.compare.to_canonical_graph``. Those labels are content-derived rather +than run-local, so the fallback remains reproducible across processes. It emits +an ``RDFCanonicalizationWarning``, and ``--diff-stable`` has no effect on that +path -- it warns rather than silently ignoring the request. + +Canonicalization itself is implemented by the +`diffable-rdf `_ library; +``linkml_runtime.utils.rdf_canonicalize.canonicalize_rdf_graph`` is a thin +adapter that re-emits the library's log warnings as Python warnings. + + Docs ---- From 2cdaff1d67f1e3a739e39e0a8e6292f82db12a92 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 11 Sep 2026 11:08:44 +0200 Subject: [PATCH 14/30] test(rdf): record the canonicalizer gaps against the extracted library Asserts each correctness property against both linkml's copy and diffable_rdf, marking whichever implementation does not hold it as a strict xfail, so the file is a ratchet in both directions. Nine gaps run one way, two of them silent data corruption. One ran the other way -- the library dropped @base on the degraded path -- and that was the last property blocking delegation. diffable-rdf 0.4.0 fixed it, so that case now passes on both sides and carries no mark. --- .../test_rdf_canonicalize_defects.py | 353 ++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py new file mode 100644 index 0000000000..806be2e3eb --- /dev/null +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py @@ -0,0 +1,353 @@ +"""Correctness differences between linkml's RDF canonicalizer and the extracted library. + +``linkml_runtime.utils.rdf_canonicalize`` and +``diffable_rdf.canonicalize_rdf_graph`` are the same code: the module was +extracted into a standalone library at the maintainers' request +(linkml/linkml#3295). The two copies have since diverged, so each test here +asserts one correctness property against *both* implementations and marks the +one that does not hold it as a strict xfail. + +Every remaining gap runs one way -- the extracted copy received fixes the local +one did not -- and two of them are silent data corruption, where the output +parses cleanly and says something the input never said. That is worse than a +crash, because a generated artifact gets committed and reviewed on the +assumption that it means what the schema meant. + +One gap used to run the other way: the library dropped ``@base`` on the +degraded path. That was the last property blocking delegation, and +diffable-rdf 0.4.0 fixed it, so the case now passes on both sides and is kept +unmarked. Asserting both directions is what made the fix land where it +belongs. + +The xfails are strict, so this file is a ratchet in both directions: when a fix +lands on either side, its case starts passing and the suite fails until the +mark is removed. +""" + +import os +import subprocess +import sys +import textwrap +import warnings + +import diffable_rdf +import pytest +import rdflib +from rdflib import BNode, Graph, Literal, Namespace, URIRef +from rdflib.namespace import RDF + +from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph as linkml_canonicalize + +EX = Namespace("http://example.org/") + +_IMPLEMENTATIONS = ( + (linkml_canonicalize, "linkml"), + (diffable_rdf.canonicalize_rdf_graph, "diffable-rdf"), +) + + +def _impls(**known_failures: str): + """Parametrize over both implementations, xfailing the ones named. + + :param known_failures: implementation id (``linkml`` / ``diffable_rdf``) + mapped to why that implementation does not hold the property. + """ + params = [] + for fn, ident in _IMPLEMENTATIONS: + reason = known_failures.get(ident.replace("-", "_")) + marks = [pytest.mark.xfail(strict=True, reason=reason)] if reason else [] + params.append(pytest.param(fn, id=ident, marks=marks)) + return params + + +def _apply(fn, graph, output_format="turtle"): + """Call an implementation, ignoring the warnings both emit on the fallback path.""" + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + return fn(graph, output_format) + + +# -------------------------------------------------------------------------- +# Silent data corruption +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "canonicalize", + _impls(linkml="passes graph.base to the serializer without checking it is safe to relativize against"), +) +def test_base_with_a_fragment_does_not_rewrite_every_iri(canonicalize): + """A base IRI ending in ``#`` must not change the graph's terms. + + Relativizing ``http://ex.org/d#a`` against base ``http://ex.org/d#`` gives + ``<#a>``, which is correct per RFC 3986 -- but rdflib's parser resolves a + fragment reference by concatenation and reads it back as + ``http://ex.org/d##a``. Every term of the graph silently changes, and the + output parses cleanly, so nothing reports it. + """ + graph = Graph(base="http://ex.org/d#") + graph.add((URIRef("http://ex.org/d#a"), EX.p, Literal("v"))) + + round_tripped = Graph() + round_tripped.parse(data=_apply(canonicalize, graph), format="turtle") + + assert {str(s) for s in round_tripped.subjects()} == {"http://ex.org/d#a"} + + +@pytest.mark.parametrize( + "canonicalize", + _impls(linkml="rdflib's collection syntax materializes a shared list tail once per referencing list"), +) +def test_a_shared_rdf_list_tail_is_not_duplicated(canonicalize): + """Two lists sharing a tail must not gain triples on the way out. + + rdflib's Turtle writer renders ``( ... )`` collection syntax per list, so a + tail referenced from two lists is written twice as two separate blank + nodes. The result asserts more than the input did. + """ + graph = Graph() + graph.bind("ex", EX) + tail = BNode() + graph.add((tail, RDF.first, Literal("shared"))) + graph.add((tail, RDF.rest, RDF.nil)) + for name in ("l1", "l2"): + head = BNode() + graph.add((EX[name], EX.list, head)) + graph.add((head, RDF.first, Literal(name))) + graph.add((head, RDF.rest, tail)) + # A relative IRI is non-standard RDF, so pyoxigraph refuses the graph and + # both implementations degrade to rdflib -- the path this defect lives on. + graph.add((URIRef("testing"), EX.p, Literal("forces-fallback"))) + + round_tripped = Graph() + round_tripped.parse(data=_apply(canonicalize, graph), format="turtle") + + assert len(round_tripped) == len(graph) + + +# -------------------------------------------------------------------------- +# Output that no parser will read +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "canonicalize", + _impls(linkml="the rdflib fallback writes N-Triples for a graph pyoxigraph already refused"), +) +def test_nt_output_either_parses_or_refuses(canonicalize): + """N-Triples admits only absolute IRIs, so a relative one must not be written. + + The fallback is taken *because* the graph holds a term pyoxigraph rejected. + Writing it out anyway produces a file that fails on line 1. Refusing with a + message naming the term and a format that can carry the graph is the fix; + returning unreadable text is not. + """ + graph = Graph() + graph.add((URIRef("testing"), EX.p, Literal("v"))) + + try: + serialized = _apply(canonicalize, graph, "nt") + except ValueError: + return # refused, with an explanation -- the correct outcome + + rdflib.Graph().parse(data=serialized, format="nt") + + +@pytest.mark.parametrize( + "canonicalize", + _impls(linkml="sorts fallback output with str.splitlines(), which breaks on U+2028 and five other characters"), +) +def test_a_unicode_line_separator_in_a_literal_survives(canonicalize): + """Sorting N-Triples lines must split on newlines only. + + N-Triples permits U+2028 raw inside a quoted literal, but + ``str.splitlines()`` breaks on it as well as on U+2029, U+0085, U+000B, + U+000C and U+001C-1E. One statement becomes two lines, the halves sort + independently, the separator is rewritten as a newline, and the document + no longer parses. + """ + graph = Graph() + graph.add((EX.s, EX.p, Literal("a\u2028b"))) + graph.add((URIRef("testing"), EX.p, Literal("forces-fallback"))) + + try: + serialized = _apply(canonicalize, graph, "nt") + except ValueError: + return # refused because of the relative IRI, before the sort + + assert "\u2028" in serialized + rdflib.Graph().parse(data=serialized, format="nt") + + +# -------------------------------------------------------------------------- +# Non-determinism, which is the property the module exists to provide +# -------------------------------------------------------------------------- + +_ACROSS_PROCESSES = textwrap.dedent( + """ + import sys, warnings + warnings.simplefilter("ignore") + from rdflib import BNode, Graph, Literal, Namespace, URIRef + from rdflib.namespace import RDF + + module, output_format, force_fallback = sys.argv[1], sys.argv[2], sys.argv[3] == "1" + if module == "linkml": + from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph + else: + from diffable_rdf import canonicalize_rdf_graph + + EX = Namespace("http://example.org/") + g = Graph() + if force_fallback: + g.add((URIRef("testing"), EX.p, Literal("forces-fallback"))) + for i in range(12): + b = BNode() + g.add((EX["s%02d" % i], EX.child, b)) + g.add((b, EX.k, Literal("v%d" % i))) + g.add((b, RDF.type, EX.T)) + for host in "abcdefgh": + g.add((URIRef("http://%s.example/s" % host), + URIRef("http://%s.example/p" % host), + URIRef("http://%s.example/o" % host))) + sys.stdout.write(canonicalize_rdf_graph(g, output_format)) + """ +) + + +def _outputs_across_processes(module, output_format, force_fallback): + """Serialize the same graph in four processes with different hash seeds.""" + outputs = set() + for seed in ("0", "1", "12345", "999"): + completed = subprocess.run( + [sys.executable, "-c", _ACROSS_PROCESSES, module, output_format, "1" if force_fallback else "0"], + capture_output=True, + text=True, + encoding="utf-8", + env={**os.environ, "PYTHONHASHSEED": seed}, + check=False, + ) + if completed.returncode != 0: + pytest.fail(f"{module}/{output_format} failed:\n{completed.stderr[-2000:]}") + outputs.add(completed.stdout) + return outputs + + +_XML_TRAVERSAL = "degraded RDF/XML is ordered by rdflib's graph traversal, which follows set iteration order" +_NS_NAMES = "auto-generated ns1/ns2 prefix names are allocated in traversal order" +_JSONLD_UNMAPPED = "json-ld is absent from the format map, so it falls through to rdflib with no determinism guarantee" + + +@pytest.mark.parametrize( + ("module", "output_format", "force_fallback"), + [ + pytest.param( + "linkml", "xml", True, id="linkml-xml-fallback", marks=pytest.mark.xfail(strict=True, reason=_XML_TRAVERSAL) + ), + pytest.param("diffable_rdf", "xml", True, id="diffable-rdf-xml-fallback"), + pytest.param( + "linkml", + "turtle", + True, + id="linkml-turtle-fallback", + marks=pytest.mark.xfail(strict=True, reason=_NS_NAMES), + ), + pytest.param("diffable_rdf", "turtle", True, id="diffable-rdf-turtle-fallback"), + pytest.param( + "linkml", + "json-ld", + False, + id="linkml-jsonld", + marks=pytest.mark.xfail(strict=True, reason=_JSONLD_UNMAPPED), + ), + pytest.param("diffable_rdf", "json-ld", False, id="diffable-rdf-jsonld"), + ], +) +def test_output_is_byte_identical_across_processes(module, output_format, force_fallback): + """The same graph must serialize to the same bytes in any process. + + Three separate causes break this in the local copy. Degraded RDF/XML is + ordered by rdflib's own graph traversal, which follows set iteration order. + Auto-generated ``ns1``/``ns2`` prefix names are allocated in traversal order + too, so the *names* move even when the triples do not. And ``json-ld`` is + not in the format map at all, so it falls through to rdflib's serializer, + which carries no determinism guarantee. + """ + assert len(_outputs_across_processes(module, output_format, force_fallback)) == 1 + + +# -------------------------------------------------------------------------- +# Interface +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "canonicalize", + _impls(linkml="returns the serializer's own trailing whitespace, which differs by format"), +) +def test_every_format_ends_with_exactly_one_newline(canonicalize): + """A committed artifact should not depend on which format wrote it. + + Passing through whatever the serializer produced gives no trailing newline + for RDF/XML and one for Turtle, so POSIX text tools disagree about whether + the file has a last line. + """ + counts = {} + for output_format in ("turtle", "nt", "xml", "trig", "n3"): + graph = Graph() + graph.bind("ex", EX) + graph.add((EX.s, EX.p, Literal("v"))) + serialized = _apply(canonicalize, graph, output_format) + counts[output_format] = len(serialized) - len(serialized.rstrip("\n")) + + assert counts == dict.fromkeys(counts, 1) + + +@pytest.mark.parametrize( + "canonicalize", + _impls(linkml="Dataset is a Graph subclass, so the type check accepts it and the named graphs are flattened"), +) +def test_a_dataset_is_refused_rather_than_partly_serialized(canonicalize): + """A Dataset is a Graph subclass, so it is accepted and quietly flattened. + + Every named graph collapses into one document and the graph names are + dropped, which is a different dataset. The caller asked for something this + function cannot express and should be told so. + """ + dataset = rdflib.Dataset() + dataset.graph(URIRef("http://ex/g1")).add((EX.a, EX.p, Literal("in-g1"))) + dataset.graph(URIRef("http://ex/g2")).add((EX.b, EX.p, Literal("in-g2"))) + + with pytest.raises(TypeError): + _apply(canonicalize, dataset) + + +# -------------------------------------------------------------------------- +# A property both implementations now hold +# -------------------------------------------------------------------------- + + +@pytest.mark.parametrize("canonicalize", _impls()) +def test_base_survives_the_degraded_path(canonicalize): + """``@base`` must not disappear just because pyoxigraph refused the graph. + + ``rdflib_dumper.dumps(..., prefix_map={"@base": ...})`` is a supported way + to ask for a document whose IRIs are written relative to a base, and the + metamodel routinely produces graphs that fall back (a bare ``status: + testing`` on a ``uriorcurie`` slot serializes to the relative ````, + which pyoxigraph rejects). Losing the directive on exactly that path means + the feature works only for graphs that never needed the fallback. + + This ran the other way until diffable-rdf 0.4.0, which was the last + property blocking delegation. The case is kept because it is the one the + two implementations reach differently: linkml passes ``graph.base`` to the + serializer unconditionally, which is why it also fails + :func:`test_base_with_a_fragment_does_not_rewrite_every_iri` above, while + the library keeps the base only after confirming that re-reading the + result still yields every absolute IRI of the source. + """ + graph = Graph(base="http://example.org/default/") + graph.bind("ex", EX) + graph.add((EX.s, EX.p, Literal("v"))) + graph.add((URIRef("testing"), EX.p, Literal("forces-fallback"))) + + assert "@base ." in _apply(canonicalize, graph) From c6ab471bfbdd0e3d4c1854f8575e4fb80f4ed852 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 11 Sep 2026 14:05:26 +0200 Subject: [PATCH 15/30] refactor(rdf): delegate canonicalization to the diffable-rdf library The implementation was extracted into diffable-rdf at the maintainers' request in linkml/linkml#3295, but linkml kept its own copy and the two drifted. This deletes the copy and calls the library, which is what the extraction was for. Nine correctness fixes come with it, each already asserted in test_rdf_canonicalize_defects.py and each previously a strict xfail on the linkml side: - a base ending in # no longer rewrites every IRI that merely shares its prefix (silent corruption: output parsed, meaning changed) - a shared rdf:List tail is no longer duplicated (9 triples in, 11 out) - N-Triples refuses a relative IRI instead of writing a file its own parser rejects - literals containing U+2028, U+2029, U+0085 and the other separators str.splitlines() treats as line breaks survive the line sort - degraded RDF/XML, degraded Turtle and json-ld are byte-identical across processes - every format ends with exactly one newline - a Dataset is refused rather than silently flattened Behaviour changes for callers: nt output for a graph containing a relative IRI now raises ValueError rather than writing an unparseable file, and json-ld is canonicalized rather than handed to rdflib, so it no longer warns. All four RDF generators produce byte-identical output. The library reports degradation through logging; linkml reports it through warnings so it is visible without logging configuration. _DegradedPathWarnings bridges the two, and the tests pin the properties that makes load-bearing: the warning is attributed to the caller's line, the library's logger is left as it was found, a caller who configured logging still receives the record, and warnings survive an exception. Signed-off-by: jdsika --- docs/howtos/collaborative-development.md | 12 +- packages/linkml_runtime/pyproject.toml | 1 + .../linkml_runtime/utils/rdf_canonicalize.py | 499 ++++-------------- .../test_utils/test_rdf_canonicalize.py | 202 ++++++- .../test_rdf_canonicalize_defects.py | 129 ++--- uv.lock | 305 ++++++----- 6 files changed, 514 insertions(+), 634 deletions(-) diff --git a/docs/howtos/collaborative-development.md b/docs/howtos/collaborative-development.md index 221337e843..278e290523 100644 --- a/docs/howtos/collaborative-development.md +++ b/docs/howtos/collaborative-development.md @@ -49,18 +49,20 @@ but an insertion can renumber existing nodes and obscure the actual schema edit. The optional `--diff-stable` flag uses [diffable-rdf](https://github.com/ASCS-eV/diffable-rdf) to reduce this label churn. -In a uv project containing your schema, install LinkML with the extra and generate -SHACL as follows (using a release that includes this option): +The library is installed with LinkML. In a uv project containing your schema, +install LinkML and generate SHACL as follows (using a release that includes this option): ```bash -uv add 'linkml[diff-stable]' +uv add linkml uv run gen-shacl --diff-stable schema.yaml > schema.shacl.ttl ``` The flag also works with `gen-owl`, `gen-rdf`, and `gen-shex --format rdf`. Python callers of `canonicalize_rdf_graph(..., diff_stable=True)` can install -`linkml-runtime[diff-stable]`. The feature is disabled by default; enabling it -changes existing labels once, so keep that regeneration separate from schema edits. +`linkml-runtime`. The `linkml[diff-stable]` and `linkml-runtime[diff-stable]` extras +remain available for compatibility with earlier installation instructions. +The feature is disabled by default; enabling it changes existing labels once, +so keep that regeneration separate from schema edits. Labels depend on blank-node neighborhoods. Edits within connected structures or among symmetric nodes can still affect other labels; minimum diffs are not diff --git a/packages/linkml_runtime/pyproject.toml b/packages/linkml_runtime/pyproject.toml index d4c0a2f167..20db1deecc 100644 --- a/packages/linkml_runtime/pyproject.toml +++ b/packages/linkml_runtime/pyproject.toml @@ -48,6 +48,7 @@ dependencies = [ "prefixmaps >=0.1.4", "curies>=0.14.6", "pyoxigraph>=0.5.11", + "diffable-rdf>=0.4.0", "pydantic>=2.13.5,<3.0.0", "isodate >=0.7.2, <1.0.0; python_version < '3.11'", ] diff --git a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py index bc06a969b8..b7380d0219 100644 --- a/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py +++ b/packages/linkml_runtime/src/linkml_runtime/utils/rdf_canonicalize.py @@ -1,48 +1,40 @@ -"""Deterministic RDF serialization via pyoxigraph RDFC-1.0 canonicalization. - -This module provides a function to canonicalize an rdflib Graph using -pyoxigraph's RDFC-1.0 implementation, producing deterministic output -with stable blank node labels and sorted triples. - -**Known limitations:** - -1. **xsd:string normalization**: pyoxigraph follows RDF 1.1, where plain - string literals and ``"text"^^xsd:string`` are identical. The output - will never contain explicit ``^^xsd:string`` annotations. Code that - re-parses the output with rdflib will see ``Literal("x")`` (datatype - ``None``) rather than ``Literal("x", datatype=XSD.string)``. - -2. **Non-standard RDF**: Graphs with literal predicates (e.g. SHACL - annotation mode) or relative IRIs (e.g. the metamodel's - ``bibo:status ``) are rejected by pyoxigraph. This function - falls back to rdflib's serializer for such graphs, but still - canonicalizes blank-node labels (and sorts line-oriented formats) so - the fallback output remains deterministic across processes. - -3. **Numeric short forms**: pyoxigraph uses Turtle short forms for - ``xsd:integer`` (``42``), ``xsd:boolean`` (``true``), and - ``xsd:decimal`` (``1.23``). rdflib parses these back with the - correct datatype, so this is lossless. - -4. **Base IRI / prefix collision**: When a graph has ``@base`` and a - prefix whose namespace equals the base IRI (e.g. rdflib's auto-bound - ``base:`` prefix), pyoxigraph emits CURIEs like ``base:label`` that - rdflib rejects. We skip such prefixes during serialization. - -5. **Trailing escaped dot in PN_LOCAL**: pyoxigraph emits CURIEs like - ``prefix:local\\.`` for IRIs whose local part ends with ``.``. This - is valid Turtle (PN_LOCAL_ESC), but rdflib's notation3 parser rejects - it because it conflicts with the statement-terminator dot. We - post-process the output to expand such CURIEs to full ```` form. +"""Deterministic RDF serialization, delegated to the ``diffable-rdf`` library. + +This module used to carry the implementation. It was extracted into +`diffable-rdf `_ at the maintainers' +request (linkml/linkml#3295: *"the implementation should live elsewhere ... +independent rdflib sidecar library?"*), and this module is now a thin adapter +over it. :func:`canonicalize_rdf_graph` keeps its signature, so nothing that +imports it needs to change. + +The adapter exists for one reason: the two projects report degraded output +differently. The library logs through the ``logging`` module, which is silent +unless the application configured a handler. linkml deliberately uses +:func:`warnings.warn` so a schema author running ``gen-owl`` sees that the +output took a fallback path without having to opt in to logging first. So the +library's warnings are captured and re-emitted as +:class:`RDFCanonicalizationWarning`. + +What the library does, in short: the graph is transferred to pyoxigraph via +N-Triples, canonicalized with RDFC-1.0, sorted, and serialized back. Graphs +pyoxigraph refuses -- literal predicates from SHACL annotation mode, relative +IRIs such as the metamodel's ``bibo:status `` -- fall back to rdflib +with blank-node labels canonicalized by :func:`rdflib.compare.to_canonical_graph`, +which is content-derived rather than run-local, so the fallback is still +reproducible across processes. The full contract, including the limitations +that used to be listed here (``xsd:string`` normalization, numeric short forms, +base/prefix collisions, ``PN_LOCAL`` escaping), is documented in the library's +``docs/api.md``. + +Delegating also adopts nine correctness fixes the extracted copy received and +this one never did, two of them silent data corruption. Each is pinned by a +test in ``tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py``. """ -import io -import re +import logging import warnings -import pyoxigraph as ox import rdflib -from rdflib.compare import to_canonical_graph class RDFCanonicalizationWarning(UserWarning): @@ -55,233 +47,59 @@ class RDFCanonicalizationWarning(UserWarning): """ -# Mapping from rdflib/LinkML format strings to pyoxigraph RdfFormat objects. -_FORMAT_MAP: dict[str, ox.RdfFormat] = { - "turtle": ox.RdfFormat.TURTLE, - "ttl": ox.RdfFormat.TURTLE, - "nt": ox.RdfFormat.N_TRIPLES, - "ntriples": ox.RdfFormat.N_TRIPLES, - "n-triples": ox.RdfFormat.N_TRIPLES, - "nt11": ox.RdfFormat.N_TRIPLES, - "nquads": ox.RdfFormat.N_QUADS, - "n-quads": ox.RdfFormat.N_QUADS, - "xml": ox.RdfFormat.RDF_XML, - "rdf/xml": ox.RdfFormat.RDF_XML, - "trig": ox.RdfFormat.TRIG, - "n3": ox.RdfFormat.N3, -} +_LIBRARY_LOGGER = "diffable_rdf" -# Formats that support prefix declarations. -_PREFIX_FORMATS = frozenset({ox.RdfFormat.TURTLE, ox.RdfFormat.TRIG, ox.RdfFormat.N3, ox.RdfFormat.RDF_XML}) -# Line-oriented formats (one triple/quad per line) whose fallback output must -# be sorted, because rdflib does not emit them in a stable order even after -# blank-node canonicalization. -_LINE_ORIENTED_FORMATS = frozenset({"nt", "ntriples", "n-triples", "nt11", "nquads", "n-quads"}) +class _DegradedPathWarnings(logging.Handler): + """Collect the library's warning logs and re-emit them as Python warnings. + Records are collected during the call and re-emitted in :meth:`__exit__` + rather than from :meth:`emit`. Warning at the moment the record arrives + would put eight ``logging`` frames plus an unknown number of library frames + between :func:`warnings.warn` and the caller, and the library's depth + differs per message, so no single ``stacklevel`` could point at the caller. + Re-emitting from ``__exit__`` makes the distance a constant. -def _deterministic_fallback_serialize(graph: rdflib.Graph, output_format: str) -> str: - """Serialize a graph that pyoxigraph cannot canonicalize, deterministically. + The handler is attached to the library's package logger rather than the + root logger. The library logs under module-level children such as + ``diffable_rdf.canonicalize``, which propagate to the package logger, so + attaching there catches every module without naming any of them. - pyoxigraph rejects some graphs that rdflib accepts -- notably graphs - containing relative IRIs (e.g. the metamodel's ``bibo:status ``) - or literal predicates (SHACL annotation mode). A plain - ``graph.serialize()`` for such graphs is *not* reproducible across - processes: rdflib assigns blank-node labels non-deterministically, so the - structure and grouping of the output varies run to run. - - To degrade gracefully instead of silently emitting non-deterministic - output, we canonicalize blank-node labels with rdflib's own - isomorphism-based canonicalization (:func:`rdflib.compare.to_canonical_graph`, - which uses a content-derived hash, not run-local ids) and preserve the - original prefix and base bindings that the canonical graph drops. For - line-oriented formats we additionally sort the serialized lines, since - rdflib does not emit N-Triples/N-Quads in a stable order. - - Relative IRIs are preserved verbatim (not resolved against the base): the - goal is deterministic output, and silently rewriting ```` into an - absolute IRI would mask what is really a data problem in the source graph. - - :param graph: The rdflib Graph that pyoxigraph could not parse. - :param output_format: Target serialization format (e.g. ``"turtle"``, ``"nt"``). - :return: Deterministic string serialization of the graph. + ``propagate`` is left alone: a caller who *did* configure logging still + gets the record through their own handlers, and seeing the message once + per mechanism is a better trade than suppressing a handler they asked for. """ - canonical = to_canonical_graph(graph) - # to_canonical_graph builds a fresh graph without the source's namespace - # bindings; rebind them so the output does not fall back to rdflib's - # non-deterministic auto-generated ``ns1:``/``ns2:`` prefixes. - for prefix, namespace in graph.namespace_manager.namespaces(): - canonical.namespace_manager.bind(prefix, namespace, replace=True) - canonical.base = graph.base - serialized = canonical.serialize(format=output_format) - if output_format.lower() in _LINE_ORIENTED_FORMATS: - lines = [line for line in serialized.splitlines() if line.strip()] - return "\n".join(sorted(lines)) + "\n" - return serialized - -# Characters that may appear escaped in a Turtle PN_LOCAL via PN_LOCAL_ESC. -_PN_LOCAL_ESC_UNESCAPE = re.compile(r"\\([_~.\-!$&'()*+,;=/?#@%])") - - -_CURIE_TRAILING_DOT_PATTERN = re.compile( - r"(?\"'\[\]]*?\\\.)" - r"(?=\s)" -) - - -def _iter_turtle_structural_spans(turtle_text: str): - """Yield ``(start, end, is_structural)`` spans of a Turtle string. - - Structural spans are regions outside of string literals, IRI refs, and - line comments — i.e. the only places where a CURIE can syntactically - appear. Non-structural spans (literal contents, IRI bodies, comments) - are yielded as-is so they round-trip unchanged. - - Handles single- and triple-quoted literals with backslash escapes for - both ``"`` and ``'`` delimiters, ``<...>`` IRI refs, and ``#...`` line - comments. - """ - n = len(turtle_text) - i = 0 - while i < n: - ch = turtle_text[i] - if ch in ('"', "'"): - # String literal — find matching delimiter, respecting triple- - # quote form and backslash escapes. - delim = ch - triple = turtle_text[i : i + 3] == delim * 3 - end_marker = delim * 3 if triple else delim - j = i + len(end_marker) - while j < n: - if turtle_text[j] == "\\" and j + 1 < n: - j += 2 - continue - if turtle_text[j : j + len(end_marker)] == end_marker: - j += len(end_marker) - break - j += 1 - yield i, j, False - i = j - elif ch == "<": - # IRI ref — find closing '>' on the same logical line. - j = turtle_text.find(">", i + 1) - if j == -1: - yield i, n, False - i = n - else: - yield i, j + 1, False - i = j + 1 - elif ch == "#": - # Line comment — to end of line. - j = turtle_text.find("\n", i) - j = n if j == -1 else j - yield i, j, False - i = j - else: - # Structural region — accumulate until the next literal / IRI / - # comment opener. Treat ``\X`` as a PN_LOCAL_ESC escape (two - # chars) so that ``\#`` and ``\.`` inside a CURIE local part - # don't trigger comment / boundary handling. - start = i - while i < n: - c = turtle_text[i] - if c == "\\" and i + 1 < n: - i += 2 - continue - if c in ('"', "'", "<", "#"): - break - i += 1 - yield start, i, True - - -def _expand_trailing_dot_curies(turtle_text: str, prefixes: dict[str, str]) -> str: - """Replace CURIEs whose local part ends in ``\\.`` with full ```` form. - - rdflib's notation3 parser rejects PN_LOCAL ending in an escaped dot - even though Turtle permits it (PN_LOCAL_ESC). pyoxigraph emits this - form for IRIs ending in ``.`` (e.g. ``biolink:StrandEnum#.``). We - rewrite each such CURIE to its expanded ```` form so the output - round-trips through rdflib. - - The rewrite is applied only to *structural* spans of the document — - string literals, IRI refs, and comments are left untouched. This is - what prevents the regex from mangling CURIE-shaped substrings that - happen to appear inside a literal value. - """ - if not prefixes: - return turtle_text - - def replace(match: re.Match[str]) -> str: - prefix = match.group(1) - local_escaped = match.group(2) - namespace = prefixes.get(prefix) - if namespace is None: - return match.group(0) - local = _PN_LOCAL_ESC_UNESCAPE.sub(r"\1", local_escaped) - return f"<{namespace}{local}>" - - parts: list[str] = [] - for start, end, is_structural in _iter_turtle_structural_spans(turtle_text): - chunk = turtle_text[start:end] - if is_structural: - chunk = _CURIE_TRAILING_DOT_PATTERN.sub(replace, chunk) - parts.append(chunk) - return "".join(parts) - - -def _iri_terms(triples: list["ox.Triple"]) -> set[str]: - """Return the set of IRI strings appearing anywhere in ``triples``. - - Walks subjects, predicates, non-literal objects, and literal datatypes. - Used to filter the prefix dict down to namespaces that are actually - referenced by the canonicalized graph, so the output isn't padded with - unused ``@prefix`` declarations. - """ - iris: set[str] = set() - for t in triples: - for term in (t.subject, t.predicate, t.object): - if isinstance(term, ox.NamedNode): - iris.add(term.value) - elif isinstance(term, ox.Literal): - dt = term.datatype - if dt is not None: - iris.add(dt.value) - return iris - - -def _filter_prefixes_to_used(prefixes: dict[str, str], used_iris: set[str]) -> dict[str, str]: - """Drop prefix bindings whose namespace is not a prefix of any used IRI. - - A prefix is kept if at least one IRI in ``used_iris`` starts with its - namespace string. Parent-namespace matches are honored (e.g. a prefix - bound to ``http://schema.org/`` is kept when ``http://schema.org/Person`` - appears in the graph). - """ - return {prefix: ns for prefix, ns in prefixes.items() if any(iri.startswith(ns) for iri in used_iris)} - - -def _is_safe_prefix_iri(iri: str) -> bool: - """Check whether a namespace IRI is safe for prefix serialization. - - pyoxigraph rejects IRIs with invalid code-points (e.g. double ``#``), - and rdflib's Turtle parser cannot round-trip CURIEs whose namespace - contains query parameters or fragments in unexpected positions. This - function returns ``False`` for such IRIs so they can be skipped during - prefix collection. - """ - # A namespace IRI should end with '/' or '#'. If '#' appears - # *before* the final character, the IRI contains an embedded - # fragment which produces unusable CURIEs. - if "#" in iri[:-1]: - return False - # Query parameters in namespace IRIs produce CURIEs that rdflib - # cannot parse back. - if "?" in iri: - return False - return True + def __init__(self) -> None: + super().__init__(level=logging.WARNING) + self._messages: list[str] = [] + self._logger = logging.getLogger(_LIBRARY_LOGGER) + self._previous_level = logging.NOTSET + + def emit(self, record: logging.LogRecord) -> None: + """Record one formatted message for re-emission on exit.""" + self._messages.append(record.getMessage()) + + def __enter__(self) -> "_DegradedPathWarnings": + """Attach to the library's logger, raising its level if it is silent.""" + self._previous_level = self._logger.level + if not self._logger.isEnabledFor(logging.WARNING): + self._logger.setLevel(logging.WARNING) + self._logger.addHandler(self) + return self + + def __exit__(self, *exc_info: object) -> None: + """Detach, restore the logger, and re-emit what was collected. + + Runs on the exception path too, so a caller who gets an exception still + learns about the degradation that preceded it. + """ + self._logger.removeHandler(self) + self._logger.setLevel(self._previous_level) + for message in self._messages: + # 3 frames: warnings.warn, this method, and the ``with`` statement + # in canonicalize_rdf_graph -- so the warning lands on its caller. + warnings.warn(message, RDFCanonicalizationWarning, stacklevel=3) def canonicalize_rdf_graph( @@ -291,151 +109,36 @@ def canonicalize_rdf_graph( ) -> str: """Serialize an rdflib Graph deterministically using RDFC-1.0 canonicalization. - The graph is transferred to pyoxigraph via N-Triples, canonicalized - with RDFC-1.0, sorted, and serialized back to the requested format. - Prefix bindings from the rdflib Graph are preserved in the output - for formats that support them (Turtle, TriG, N3, RDF/XML). + The graph is transferred to pyoxigraph via N-Triples, canonicalized with + RDFC-1.0, sorted, and serialized back to the requested format. Prefix + bindings from the rdflib Graph are preserved in the output for formats that + support them (Turtle, TriG, N3, RDF/XML). - Falls back to plain rdflib serialization for unsupported formats or - graphs containing non-standard RDF (e.g. literal predicates). + Falls back to plain rdflib serialization for unsupported formats or graphs + containing non-standard RDF (e.g. literal predicates), warning with + :class:`RDFCanonicalizationWarning` so that the caller knows the output is + less strongly guaranteed than usual. :param graph: The rdflib Graph to serialize. :param output_format: Target serialization format (e.g. ``"turtle"``, ``"nt"``). :param diff_stable: Use diffable-rdf labels to reduce blank-node churn across - edits. Requires the ``diff-stable`` extra; disabled by default. The + edits. Disabled by default; diffable-rdf is installed with the runtime. The non-standard RDF fallback warns when it cannot apply these labels. See https://linkml.io/linkml/howtos/collaborative-development.html#rdf-in-version-control :return: Deterministic string serialization of the graph. - :raises ImportError: If diff-stable labels are requested without diffable-rdf. + :raises ImportError: If diffable-rdf is unavailable, with an installation command. + :raises ValueError: If the graph cannot be serialized to ``output_format`` + in a form that parses back -- notably a line-oriented format asked to + write a relative IRI, which N-Triples forbids. """ - if diff_stable: - try: - from diffable_rdf import wl_relabel_quads - except ImportError as exc: - raise ImportError( - "diff_stable=True requires diffable-rdf. Install " - "'linkml-runtime[diff-stable]' or 'linkml[diff-stable]'." - ) from exc - - ox_format = _FORMAT_MAP.get(output_format.lower()) - if ox_format is None: - warnings.warn( - f"pyoxigraph does not support format {output_format!r}; falling back to " - "rdflib serializer. Output will not be deterministically canonicalized.", - RDFCanonicalizationWarning, - stacklevel=2, - ) - return graph.serialize(format=output_format) - - # 1. Transfer rdflib graph to pyoxigraph via N-Triples. - nt_data = graph.serialize(format="nt") - nt_bytes = nt_data.encode("utf-8") if isinstance(nt_data, str) else nt_data - - # 2. Parse into pyoxigraph and build a Dataset for canonicalization. - # Fall back to rdflib if the graph contains non-standard RDF - # (e.g. literal predicates from annotations) that pyoxigraph rejects. - try: - triples = list(ox.parse(io.BytesIO(nt_bytes), format=ox.RdfFormat.N_TRIPLES)) - except SyntaxError: - warnings.warn( - "Graph contains non-standard RDF (e.g. relative IRIs or literal predicates) " - "that pyoxigraph cannot parse; falling back to rdflib. Output is still " - "deterministic (blank-node labels are canonicalized via rdflib) but is not " - "canonicalized with pyoxigraph RDFC-1.0.", - RDFCanonicalizationWarning, - stacklevel=2, - ) - if diff_stable: - warnings.warn( - "diff_stable=True was requested but this graph took the rdflib fallback, " - "which cannot apply Weisfeiler-Lehman blank-node labels. Output is " - "deterministic but NOT diff-stable: an unrelated edit may still renumber " - "blank nodes. Make the offending terms standard RDF (absolute IRIs, IRI " - "predicates) to get diff-stable labels.", - RDFCanonicalizationWarning, - stacklevel=2, - ) - return _deterministic_fallback_serialize(graph, output_format) - - dataset = ox.Dataset() - for triple in triples: - dataset.add(ox.Quad(triple.subject, triple.predicate, triple.object, ox.DefaultGraph())) - - # 3. Canonicalize blank node labels with RDFC-1.0. - dataset.canonicalize(ox.CanonicalizationAlgorithm.RDFC_1_0) - - quads = list(dataset) - - if diff_stable: - quads = wl_relabel_quads(quads) - - # 4. Sort triples for deterministic ordering. - # RDFC-1.0 stabilizes blank-node labels but pyoxigraph's Dataset - # iteration order is not sorted and varies across processes (verified - # empirically against pyoxigraph 0.5.8). The explicit string-key sort - # is load-bearing for byte-identical output across runs; see - # tests/linkml_runtime/test_utils/test_rdf_canonicalize.py::test_sort_is_load_bearing. - sorted_triples = sorted( - (ox.Triple(q.subject, q.predicate, q.object) for q in quads), - key=lambda t: (str(t.subject), str(t.predicate), str(t.object)), - ) - - # 5. Collect prefixes for formats that support them. - base_iri = str(graph.base) if graph.base else None - prefixes: dict[str, str] | None = None - if ox_format in _PREFIX_FORMATS: - prefixes = {} - for prefix, namespace in graph.namespace_manager.namespaces(): - if not prefix: # skip empty prefix (base) - continue - ns_str = str(namespace) - # Skip prefixes whose namespace matches the base IRI to avoid - # pyoxigraph emitting CURIEs like `base:label` that conflict - # with the @base directive. - if base_iri and ns_str == base_iri: - continue - # Skip namespace IRIs that pyoxigraph rejects or that produce - # CURIEs rdflib cannot round-trip. Valid namespace IRIs for - # prefix use should end with '/' or '#' and contain no query - # parameters or fragment-like characters in the middle. - if not _is_safe_prefix_iri(ns_str): - continue - prefixes[str(prefix)] = ns_str - # Drop prefix bindings whose namespace is not referenced by any IRI - # in the graph. This prevents the rdflib NamespaceManager's default - # bindings (~30 well-known vocabularies) from being emitted into - # every output file regardless of whether the schema actually uses - # them. - prefixes = _filter_prefixes_to_used(prefixes, _iri_terms(sorted_triples)) - used_prefixes = prefixes try: - result_bytes = ox.serialize( - sorted_triples, - format=ox_format, - prefixes=prefixes, - base_iri=base_iri, - ) - except ValueError as e: - # pyoxigraph 0.5.x reports rejected prefix IRIs with a message - # that begins with "Invalid prefix" (verified empirically). Only - # swallow that case and retry without prefixes — any other - # ValueError (e.g. invalid base IRI, or an unrelated future - # serializer bug) must propagate so it surfaces as a stack trace - # rather than silently dropping all prefix declarations. - if not str(e).startswith("Invalid prefix"): - raise - warnings.warn( - f"pyoxigraph rejected one or more prefix IRIs ({e}); serializing without " - "prefix declarations. Output remains canonicalized but is more verbose.", - RDFCanonicalizationWarning, - stacklevel=2, - ) - result_bytes = ox.serialize( - sorted_triples, - format=ox_format, - ) - used_prefixes = None - result = result_bytes.decode("utf-8") - if ox_format in _PREFIX_FORMATS and used_prefixes: - result = _expand_trailing_dot_curies(result, used_prefixes) - return result + from diffable_rdf import canonicalize_rdf_graph as _canonicalize_rdf_graph + except ImportError as exc: + raise ImportError( + "diffable-rdf is required for RDF serialization. Install with: pip install 'linkml-runtime[diff-stable]'" + ) from exc + + # Frames between warnings.warn and this function's caller are counted in + # _DegradedPathWarnings.__exit__, which is where the re-emission happens. + with _DegradedPathWarnings(): + return _canonicalize_rdf_graph(graph, output_format=output_format, diff_stable=diff_stable) diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py index 8da48fa8b3..f5e869fecc 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize.py @@ -1,11 +1,15 @@ """Tests for deterministic RDF serialization via pyoxigraph RDFC-1.0.""" import difflib +import json +import logging import os import re import subprocess import sys import textwrap +import warnings +from collections.abc import Callable from pathlib import Path import pyoxigraph as ox @@ -311,13 +315,13 @@ def fake_serialize(*args, **kwargs): raise ValueError("Invalid prefix bad IRI 'http://example.com/ has space/', Invalid IRI code point ' '") return real_serialize(*args, **kwargs) - monkeypatch.setattr(rdf_canon_mod.ox, "serialize", fake_serialize) + monkeypatch.setattr(ox, "serialize", fake_serialize) g = Graph() g.bind("ex", "http://example.com/") g.add((URIRef("http://example.com/a"), RDF.type, URIRef("http://example.com/Thing"))) - with pytest.warns(RDFCanonicalizationWarning, match="rejected one or more prefix IRIs"): + with pytest.warns(RDFCanonicalizationWarning, match="rejected the prefix or base IRIs"): ttl = canonicalize_rdf_graph(g, output_format="turtle") # Fallback retry should succeed and the output still round-trips. g2 = Graph() @@ -326,22 +330,74 @@ def fake_serialize(*args, **kwargs): def test_unsupported_format_warns_and_falls_back(): - """A format pyoxigraph doesn't know about falls back to rdflib with a warning.""" + """A format the canonicalizer doesn't know about falls back to rdflib with a warning. + + ``longturtle`` is an rdflib serializer plugin with no pyoxigraph + equivalent, so it can only be produced by delegating to rdflib, and + rdflib orders that output by graph traversal. The warning is what tells + the caller the determinism guarantee does not extend to this format. + """ g = Graph() g.bind("ex", "http://example.com/") g.add((URIRef("http://example.com/a"), RDF.type, URIRef("http://example.com/Thing"))) - with pytest.warns(RDFCanonicalizationWarning, match="does not support format"): - result = canonicalize_rdf_graph(g, output_format="json-ld") + with pytest.warns(RDFCanonicalizationWarning, match="not one of the formats"): + result = canonicalize_rdf_graph(g, output_format="longturtle") # The fallback should still produce valid output in the requested format. assert result +def test_json_ld_is_canonicalized_rather_than_handed_to_rdflib(): + """``json-ld`` gets the determinism guarantee, not a fallback warning. + + rdflib's JSON-LD serializer emits objects in traversal order, so this used + to be an unsupported format that warned and returned whatever rdflib felt + like. It is now produced deterministically, which is why no warning is due. + """ + g = _make_graph_with_bnodes() + with warnings.catch_warnings(): + warnings.simplefilter("error", RDFCanonicalizationWarning) + result = canonicalize_rdf_graph(g, output_format="json-ld") + assert json.loads(result) + + +def test_a_rejected_base_iri_is_recovered_rather_than_fatal(monkeypatch): + """A base pyoxigraph refuses costs the base directive, not the document. + + rdflib accepts a relative or otherwise unusable ``base``, and pyoxigraph + then refuses it at serialization time. Dropping the base and retrying + yields a correct document; failing the whole call over a directive that is + optional in every output format does not. + """ + real_serialize = ox.serialize + calls = {"n": 0} + + def fake_serialize(*args, **kwargs): + calls["n"] += 1 + if calls["n"] == 1: + raise ValueError("Invalid base IRI 'broken', Invalid IRI code point ' '") + return real_serialize(*args, **kwargs) + + monkeypatch.setattr(ox, "serialize", fake_serialize) + + g = Graph() + g.bind("ex", "http://example.com/") + g.add((URIRef("http://example.com/a"), RDF.type, URIRef("http://example.com/Thing"))) + + with pytest.warns(RDFCanonicalizationWarning, match="rejected the prefix or base IRIs"): + ttl = canonicalize_rdf_graph(g, output_format="turtle") + + g2 = Graph() + g2.parse(data=ttl, format="turtle") + assert rdflib.compare.isomorphic(g, g2) + + def test_unrelated_value_error_propagates(monkeypatch): - """Any ``ValueError`` whose message doesn't begin with ``Invalid prefix`` is re-raised. + """A ``ValueError`` that is not about prefixes or the base must be re-raised. - Guards against future regressions where a pyoxigraph serializer bug - raises a different ``ValueError`` and gets silently swallowed by the - invalid-prefix fallback path. + The retry-without-prefixes path exists for two specific pyoxigraph + complaints. Widening it to every ``ValueError`` would let a serializer bug + be papered over by a retry that happens to succeed, and the caller would + get a plausible-looking document with no indication anything went wrong. """ real_serialize = ox.serialize calls = {"count": 0} @@ -350,16 +406,16 @@ def fake_serialize(*args, **kwargs): calls["count"] += 1 # First call is the prefixed serialize — raise an unrelated ValueError. if calls["count"] == 1: - raise ValueError("Invalid base IRI 'broken', Invalid IRI code point ' '") + raise ValueError("BUG: serializer state corrupted") return real_serialize(*args, **kwargs) - monkeypatch.setattr(rdf_canon_mod.ox, "serialize", fake_serialize) + monkeypatch.setattr(ox, "serialize", fake_serialize) g = Graph() g.bind("ex", "http://example.com/") g.add((URIRef("http://example.com/a"), RDF.type, URIRef("http://example.com/Thing"))) - with pytest.raises(ValueError, match="Invalid base IRI"): + with pytest.raises(ValueError, match="BUG: serializer state corrupted"): canonicalize_rdf_graph(g, output_format="turtle") @@ -462,7 +518,7 @@ def test_fallback_preserves_relative_iri(): assert "" in result -@pytest.mark.parametrize("output_format", ["turtle", "nt"]) +@pytest.mark.parametrize("output_format", ["turtle", "xml"]) def test_fallback_is_deterministic_across_processes(output_format): """The rdflib fallback produces byte-identical output across processes. @@ -470,9 +526,15 @@ def test_fallback_is_deterministic_across_processes(output_format): parse to fail, exercising the fallback path. The graph also contains several blank nodes: a plain ``graph.serialize()`` would label them non-deterministically, so this test would fail without the blank-node - canonicalization in ``_deterministic_fallback_serialize``. Two - subprocesses with different ``PYTHONHASHSEED`` values must agree byte for - byte. + canonicalization in the fallback. Two subprocesses with different + ``PYTHONHASHSEED`` values must agree byte for byte. + + ``nt`` is not covered here because a fallback graph is by definition one + pyoxigraph refused, and for this graph the reason is a relative IRI, which + N-Triples forbids outright (N-Triples 1.1 §2.2). There is no deterministic + N-Triples document to produce, so the correct answer is a refusal -- + asserted by ``test_nt_fallback_refuses_rather_than_writing_an_unparseable_file`` + below. """ program = textwrap.dedent( f""" @@ -516,6 +578,24 @@ def run(seed: str) -> str: ) +def test_nt_fallback_refuses_rather_than_writing_an_unparseable_file(): + """N-Triples output for a graph with a relative IRI must raise, not lie. + + rdflib's N-Triples serializer reuses Turtle's term rendering and does not + enforce the absolute-IRI rule, so asking it for ``nt`` here yields a file + containing ```` that its own parser then rejects. N-Triples 1.1 + §2.2 permits only absolute IRIs, so no valid document exists for this + graph and a refusal is the only honest answer. Writing the file instead + defers the failure to whoever tries to read it. + """ + g = Graph() + g.bind("ex", "http://example.com/") + g.add((URIRef("http://example.com/s"), URIRef("http://purl.org/ontology/bibo/status"), URIRef("testing"))) + + with pytest.raises(ValueError, match="not an absolute IRI"): + canonicalize_rdf_graph(g, output_format="nt") + + def _shapes_graph(count: int, extra: bool = False) -> Graph: """A graph shaped like generator output: one blank node per named subject.""" g = Graph() @@ -544,6 +624,19 @@ def test_diff_stable_without_the_extra_names_the_extra(mock_missing_import) -> N canonicalize_rdf_graph(_make_graph_with_bnodes(), diff_stable=True) +def test_default_serialization_without_the_library_names_the_dependency( + mock_missing_import: Callable[[str], None], +) -> None: + """Default serialization also explains how to repair a missing library installation.""" + mock_missing_import("diffable_rdf") + + with pytest.raises(ImportError, match="diffable-rdf is required for RDF serialization") as error: + canonicalize_rdf_graph(_make_graph_with_bnodes()) + + assert "pip install 'linkml-runtime[diff-stable]'" in str(error.value) + assert isinstance(error.value.__cause__, ImportError) + + @pytest.mark.parametrize("diff_stable", [False, pytest.param(True, marks=pytest.mark.diffable_rdf)]) @pytest.mark.parametrize("output_format", ["nt", "turtle"]) def test_diff_stable_preserves_semantics(diff_stable: bool, output_format: str) -> None: @@ -750,8 +843,83 @@ def test_diff_stable_warns_instead_of_silently_no_opping_on_the_fallback() -> No """A relative IRI requires the fallback and a warning that labels cannot apply.""" graph = _make_graph_with_bnodes() graph.add((URIRef("testing"), URIRef("http://example.com/p"), Literal("v"))) - with pytest.warns(RDFCanonicalizationWarning, match="NOT diff-stable"): + with pytest.warns(RDFCanonicalizationWarning, match="not diff-stable"): stable = canonicalize_rdf_graph(graph, diff_stable=True) with pytest.warns(RDFCanonicalizationWarning): plain = canonicalize_rdf_graph(graph, diff_stable=False) assert stable == plain + + +# -------------------------------------------------------------------------- +# The bridge from the library's logging to linkml's warnings +# -------------------------------------------------------------------------- + + +def _fallback_graph() -> Graph: + """A graph pyoxigraph refuses, so every call takes a degraded path.""" + g = Graph() + g.bind("ex", "http://example.com/") + g.add((URIRef("http://example.com/s"), URIRef("http://purl.org/ontology/bibo/status"), URIRef("testing"))) + return g + + +def test_degraded_path_warning_points_at_the_caller(): + """The warning must name the line that asked for the serialization. + + ``diffable-rdf`` reports degradation through ``logging``; linkml re-emits + it through ``warnings`` so it is visible without logging configuration. + A re-emitted warning is only actionable if it is attributed to the caller + rather than to the adapter, and the number of frames in between is not + something a reader can eyeball -- hence this test. + """ + graph = _fallback_graph() + + with pytest.warns(RDFCanonicalizationWarning) as caught: + canonicalize_rdf_graph(graph, output_format="turtle") # attribution target + + assert caught[0].filename == __file__ + source = Path(caught[0].filename).read_text(encoding="utf-8").splitlines() + assert "# attribution target" in source[caught[0].lineno - 1] + + +def test_bridging_does_not_leave_the_library_logger_modified(): + """Raising the library's log level to capture records must not be permanent. + + The adapter has to enable ``WARNING`` on the ``diffable_rdf`` logger to see + anything, which is a global mutation. Leaving it raised would silently + change logging behaviour for the rest of the process. + """ + logger = logging.getLogger("diffable_rdf") + level_before = logger.level + handlers_before = list(logger.handlers) + + with pytest.warns(RDFCanonicalizationWarning): + canonicalize_rdf_graph(_fallback_graph(), output_format="turtle") + + assert logger.level == level_before + assert logger.handlers == handlers_before + + +def test_a_caller_who_configured_logging_still_receives_the_record(caplog): + """Re-emitting as a warning must not steal the record from a log handler. + + An application that deliberately configured the ``diffable_rdf`` logger is + asking for these records. The adapter adds a mechanism; it does not get to + remove one. + """ + with caplog.at_level(logging.WARNING, logger="diffable_rdf"), pytest.warns(RDFCanonicalizationWarning): + canonicalize_rdf_graph(_fallback_graph(), output_format="turtle") + + assert any("non-standard RDF" in record.getMessage() for record in caplog.records) + + +def test_warnings_survive_an_exception_from_the_library(): + """Degradation reported before a failure must still reach the caller. + + ``nt`` output for this graph degrades to rdflib and *then* refuses, because + N-Triples has no way to write the relative IRI. Dropping the warning + because the call ended in an exception would hide the first half of the + story, which is the half that explains the second. + """ + with pytest.warns(RDFCanonicalizationWarning, match="non-standard RDF"), pytest.raises(ValueError): + canonicalize_rdf_graph(_fallback_graph(), output_format="nt") diff --git a/tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py b/tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py index 806be2e3eb..ffca5ae447 100644 --- a/tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py +++ b/tests/linkml_runtime/test_utils/test_rdf_canonicalize_defects.py @@ -1,27 +1,24 @@ -"""Correctness differences between linkml's RDF canonicalizer and the extracted library. +"""Correctness properties shared by linkml's RDF canonicalizer and the library behind it. ``linkml_runtime.utils.rdf_canonicalize`` and ``diffable_rdf.canonicalize_rdf_graph`` are the same code: the module was extracted into a standalone library at the maintainers' request -(linkml/linkml#3295). The two copies have since diverged, so each test here -asserts one correctness property against *both* implementations and marks the -one that does not hold it as a strict xfail. - -Every remaining gap runs one way -- the extracted copy received fixes the local -one did not -- and two of them are silent data corruption, where the output -parses cleanly and says something the input never said. That is worse than a -crash, because a generated artifact gets committed and reviewed on the -assumption that it means what the schema meant. - -One gap used to run the other way: the library dropped ``@base`` on the -degraded path. That was the last property blocking delegation, and -diffable-rdf 0.4.0 fixed it, so the case now passes on both sides and is kept -unmarked. Asserting both directions is what made the fix land where it -belongs. - -The xfails are strict, so this file is a ratchet in both directions: when a fix -lands on either side, its case starts passing and the suite fails until the -mark is removed. +(linkml/linkml#3295). The two copies then diverged for a while, and this file +is what made the divergence visible -- each test asserts one correctness +property against *both* implementations, so a fix that landed on one side and +not the other showed up as a failure rather than as nothing at all. + +Nine properties held only in the library, two of them cases of silent data +corruption where the output parsed cleanly and said something the input never +said. One held only in linkml: the library dropped ``@base`` on the degraded +path. Asserting both directions is what got each of them fixed where it +belonged -- the ``@base`` gap in diffable-rdf 0.4.0, the other nine in linkml +by deleting the in-tree copy and calling the library. + +Every case is now unmarked, which is the point: the file is the evidence that +the delegation changed no behaviour it should not have, and it keeps running +against both entry points so that a future divergence fails the suite instead +of going unnoticed. """ import os @@ -46,18 +43,15 @@ ) -def _impls(**known_failures: str): - """Parametrize over both implementations, xfailing the ones named. +def _impls(): + """Parametrize a test over both entry points. - :param known_failures: implementation id (``linkml`` / ``diffable_rdf``) - mapped to why that implementation does not hold the property. + linkml's is a thin adapter over the library's, so the two agree by + construction today. Running both anyway is what turns a future re-fork, or + an adapter that quietly changes behaviour on the way through, into a test + failure. """ - params = [] - for fn, ident in _IMPLEMENTATIONS: - reason = known_failures.get(ident.replace("-", "_")) - marks = [pytest.mark.xfail(strict=True, reason=reason)] if reason else [] - params.append(pytest.param(fn, id=ident, marks=marks)) - return params + return [pytest.param(fn, id=ident) for fn, ident in _IMPLEMENTATIONS] def _apply(fn, graph, output_format="turtle"): @@ -72,10 +66,7 @@ def _apply(fn, graph, output_format="turtle"): # -------------------------------------------------------------------------- -@pytest.mark.parametrize( - "canonicalize", - _impls(linkml="passes graph.base to the serializer without checking it is safe to relativize against"), -) +@pytest.mark.parametrize("canonicalize", _impls()) def test_base_with_a_fragment_does_not_rewrite_every_iri(canonicalize): """A base IRI ending in ``#`` must not change the graph's terms. @@ -94,10 +85,7 @@ def test_base_with_a_fragment_does_not_rewrite_every_iri(canonicalize): assert {str(s) for s in round_tripped.subjects()} == {"http://ex.org/d#a"} -@pytest.mark.parametrize( - "canonicalize", - _impls(linkml="rdflib's collection syntax materializes a shared list tail once per referencing list"), -) +@pytest.mark.parametrize("canonicalize", _impls()) def test_a_shared_rdf_list_tail_is_not_duplicated(canonicalize): """Two lists sharing a tail must not gain triples on the way out. @@ -130,10 +118,7 @@ def test_a_shared_rdf_list_tail_is_not_duplicated(canonicalize): # -------------------------------------------------------------------------- -@pytest.mark.parametrize( - "canonicalize", - _impls(linkml="the rdflib fallback writes N-Triples for a graph pyoxigraph already refused"), -) +@pytest.mark.parametrize("canonicalize", _impls()) def test_nt_output_either_parses_or_refuses(canonicalize): """N-Triples admits only absolute IRIs, so a relative one must not be written. @@ -153,10 +138,7 @@ def test_nt_output_either_parses_or_refuses(canonicalize): rdflib.Graph().parse(data=serialized, format="nt") -@pytest.mark.parametrize( - "canonicalize", - _impls(linkml="sorts fallback output with str.splitlines(), which breaks on U+2028 and five other characters"), -) +@pytest.mark.parametrize("canonicalize", _impls()) def test_a_unicode_line_separator_in_a_literal_survives(canonicalize): """Sorting N-Triples lines must split on newlines only. @@ -240,37 +222,24 @@ def _outputs_across_processes(module, output_format, force_fallback): @pytest.mark.parametrize( ("module", "output_format", "force_fallback"), [ - pytest.param( - "linkml", "xml", True, id="linkml-xml-fallback", marks=pytest.mark.xfail(strict=True, reason=_XML_TRAVERSAL) - ), + pytest.param("linkml", "xml", True, id="linkml-xml-fallback"), pytest.param("diffable_rdf", "xml", True, id="diffable-rdf-xml-fallback"), - pytest.param( - "linkml", - "turtle", - True, - id="linkml-turtle-fallback", - marks=pytest.mark.xfail(strict=True, reason=_NS_NAMES), - ), + pytest.param("linkml", "turtle", True, id="linkml-turtle-fallback"), pytest.param("diffable_rdf", "turtle", True, id="diffable-rdf-turtle-fallback"), - pytest.param( - "linkml", - "json-ld", - False, - id="linkml-jsonld", - marks=pytest.mark.xfail(strict=True, reason=_JSONLD_UNMAPPED), - ), + pytest.param("linkml", "json-ld", False, id="linkml-jsonld"), pytest.param("diffable_rdf", "json-ld", False, id="diffable-rdf-jsonld"), ], ) def test_output_is_byte_identical_across_processes(module, output_format, force_fallback): """The same graph must serialize to the same bytes in any process. - Three separate causes break this in the local copy. Degraded RDF/XML is - ordered by rdflib's own graph traversal, which follows set iteration order. - Auto-generated ``ns1``/``ns2`` prefix names are allocated in traversal order - too, so the *names* move even when the triples do not. And ``json-ld`` is - not in the format map at all, so it falls through to rdflib's serializer, - which carries no determinism guarantee. + Three separate causes used to break this in the in-tree copy, and all three + are fixed by delegating. Degraded RDF/XML was ordered by rdflib's own graph + traversal, which follows set iteration order. Auto-generated ``ns1``/``ns2`` + prefix names were allocated in traversal order too, so the *names* moved + even when the triples did not. And ``json-ld`` was not in the format map at + all, so it fell through to rdflib's serializer, which carries no + determinism guarantee. """ assert len(_outputs_across_processes(module, output_format, force_fallback)) == 1 @@ -280,10 +249,7 @@ def test_output_is_byte_identical_across_processes(module, output_format, force_ # -------------------------------------------------------------------------- -@pytest.mark.parametrize( - "canonicalize", - _impls(linkml="returns the serializer's own trailing whitespace, which differs by format"), -) +@pytest.mark.parametrize("canonicalize", _impls()) def test_every_format_ends_with_exactly_one_newline(canonicalize): """A committed artifact should not depend on which format wrote it. @@ -302,10 +268,7 @@ def test_every_format_ends_with_exactly_one_newline(canonicalize): assert counts == dict.fromkeys(counts, 1) -@pytest.mark.parametrize( - "canonicalize", - _impls(linkml="Dataset is a Graph subclass, so the type check accepts it and the named graphs are flattened"), -) +@pytest.mark.parametrize("canonicalize", _impls()) def test_a_dataset_is_refused_rather_than_partly_serialized(canonicalize): """A Dataset is a Graph subclass, so it is accepted and quietly flattened. @@ -337,13 +300,11 @@ def test_base_survives_the_degraded_path(canonicalize): which pyoxigraph rejects). Losing the directive on exactly that path means the feature works only for graphs that never needed the fallback. - This ran the other way until diffable-rdf 0.4.0, which was the last - property blocking delegation. The case is kept because it is the one the - two implementations reach differently: linkml passes ``graph.base`` to the - serializer unconditionally, which is why it also fails - :func:`test_base_with_a_fragment_does_not_rewrite_every_iri` above, while - the library keeps the base only after confirming that re-reading the - result still yields every absolute IRI of the source. + This ran the other way until diffable-rdf 0.4.0. It was the last property + blocking delegation, because linkml held it and the library did not, so + adopting the library would have been a regression. Fixing it there rather + than keeping the in-tree copy alive is what let the other nine gaps close + at once. """ graph = Graph(base="http://example.org/default/") graph.bind("ex", EX) diff --git a/uv.lock b/uv.lock index 4b9436e0e6..f45070d4ed 100644 --- a/uv.lock +++ b/uv.lock @@ -2633,6 +2633,7 @@ dependencies = [ { name = "click" }, { name = "curies" }, { name = "deprecated" }, + { name = "diffable-rdf" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'" }, { name = "json-flattener" }, @@ -2668,6 +2669,7 @@ requires-dist = [ { name = "coverage", marker = "extra == 'dev'" }, { name = "curies", specifier = ">=0.14.6" }, { name = "deprecated" }, + { name = "diffable-rdf", specifier = ">=0.4.0" }, { name = "diffable-rdf", marker = "extra == 'diff-stable'", specifier = ">=0.4.0" }, { name = "hbreader" }, { name = "isodate", marker = "python_full_version < '3.11'", specifier = ">=0.7.2,<1.0.0" }, @@ -2943,140 +2945,183 @@ wheels = [ [[package]] name = "multidict" -version = "6.7.0" +version = "6.9.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/80/1e/5492c365f222f907de1039b91f922b93fa4f764c713ee858d235495d8f50/multidict-6.7.0.tar.gz", hash = "sha256:c6e99d9a65ca282e578dfea819cfa9c0a62b2499d8677392e09feaf305e9e6f5", size = 101834, upload-time = "2025-10-06T14:52:30.657Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a9/63/7bdd4adc330abcca54c85728db2327130e49e52e8c3ce685cec44e0f2e9f/multidict-6.7.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:9f474ad5acda359c8758c8accc22032c6abe6dc87a8be2440d097785e27a9349", size = 77153, upload-time = "2025-10-06T14:48:26.409Z" }, - { url = "https://files.pythonhosted.org/packages/3f/bb/b6c35ff175ed1a3142222b78455ee31be71a8396ed3ab5280fbe3ebe4e85/multidict-6.7.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4b7a9db5a870f780220e931d0002bbfd88fb53aceb6293251e2c839415c1b20e", size = 44993, upload-time = "2025-10-06T14:48:28.4Z" }, - { url = "https://files.pythonhosted.org/packages/e0/1f/064c77877c5fa6df6d346e68075c0f6998547afe952d6471b4c5f6a7345d/multidict-6.7.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03ca744319864e92721195fa28c7a3b2bc7b686246b35e4078c1e4d0eb5466d3", size = 44607, upload-time = "2025-10-06T14:48:29.581Z" }, - { url = "https://files.pythonhosted.org/packages/04/7a/bf6aa92065dd47f287690000b3d7d332edfccb2277634cadf6a810463c6a/multidict-6.7.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f0e77e3c0008bc9316e662624535b88d360c3a5d3f81e15cf12c139a75250046", size = 241847, upload-time = "2025-10-06T14:48:32.107Z" }, - { url = "https://files.pythonhosted.org/packages/94/39/297a8de920f76eda343e4ce05f3b489f0ab3f9504f2576dfb37b7c08ca08/multidict-6.7.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08325c9e5367aa379a3496aa9a022fe8837ff22e00b94db256d3a1378c76ab32", size = 242616, upload-time = "2025-10-06T14:48:34.054Z" }, - { url = "https://files.pythonhosted.org/packages/39/3a/d0eee2898cfd9d654aea6cb8c4addc2f9756e9a7e09391cfe55541f917f7/multidict-6.7.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e2862408c99f84aa571ab462d25236ef9cb12a602ea959ba9c9009a54902fc73", size = 222333, upload-time = "2025-10-06T14:48:35.9Z" }, - { url = "https://files.pythonhosted.org/packages/05/48/3b328851193c7a4240815b71eea165b49248867bbb6153a0aee227a0bb47/multidict-6.7.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d72a9a2d885f5c208b0cb91ff2ed43636bb7e345ec839ff64708e04f69a13cc", size = 253239, upload-time = "2025-10-06T14:48:37.302Z" }, - { url = "https://files.pythonhosted.org/packages/b1/ca/0706a98c8d126a89245413225ca4a3fefc8435014de309cf8b30acb68841/multidict-6.7.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:478cc36476687bac1514d651cbbaa94b86b0732fb6855c60c673794c7dd2da62", size = 251618, upload-time = "2025-10-06T14:48:38.963Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/9c7992f245554d8b173f6f0a048ad24b3e645d883f096857ec2c0822b8bd/multidict-6.7.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6843b28b0364dc605f21481c90fadb5f60d9123b442eb8a726bb74feef588a84", size = 241655, upload-time = "2025-10-06T14:48:40.312Z" }, - { url = "https://files.pythonhosted.org/packages/31/79/26a85991ae67efd1c0b1fc2e0c275b8a6aceeb155a68861f63f87a798f16/multidict-6.7.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:23bfeee5316266e5ee2d625df2d2c602b829435fc3a235c2ba2131495706e4a0", size = 239245, upload-time = "2025-10-06T14:48:41.848Z" }, - { url = "https://files.pythonhosted.org/packages/14/1e/75fa96394478930b79d0302eaf9a6c69f34005a1a5251ac8b9c336486ec9/multidict-6.7.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:680878b9f3d45c31e1f730eef731f9b0bc1da456155688c6745ee84eb818e90e", size = 233523, upload-time = "2025-10-06T14:48:43.749Z" }, - { url = "https://files.pythonhosted.org/packages/b2/5e/085544cb9f9c4ad2b5d97467c15f856df8d9bac410cffd5c43991a5d878b/multidict-6.7.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:eb866162ef2f45063acc7a53a88ef6fe8bf121d45c30ea3c9cd87ce7e191a8d4", size = 243129, upload-time = "2025-10-06T14:48:45.225Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c3/e9d9e2f20c9474e7a8fcef28f863c5cbd29bb5adce6b70cebe8bdad0039d/multidict-6.7.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:df0e3bf7993bdbeca5ac25aa859cf40d39019e015c9c91809ba7093967f7a648", size = 248999, upload-time = "2025-10-06T14:48:46.703Z" }, - { url = "https://files.pythonhosted.org/packages/b5/3f/df171b6efa3239ae33b97b887e42671cd1d94d460614bfb2c30ffdab3b95/multidict-6.7.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:661709cdcd919a2ece2234f9bae7174e5220c80b034585d7d8a755632d3e2111", size = 243711, upload-time = "2025-10-06T14:48:48.146Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2f/9b5564888c4e14b9af64c54acf149263721a283aaf4aa0ae89b091d5d8c1/multidict-6.7.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:096f52730c3fb8ed419db2d44391932b63891b2c5ed14850a7e215c0ba9ade36", size = 237504, upload-time = "2025-10-06T14:48:49.447Z" }, - { url = "https://files.pythonhosted.org/packages/6c/3a/0bd6ca0f7d96d790542d591c8c3354c1e1b6bfd2024d4d92dc3d87485ec7/multidict-6.7.0-cp310-cp310-win32.whl", hash = "sha256:afa8a2978ec65d2336305550535c9c4ff50ee527914328c8677b3973ade52b85", size = 41422, upload-time = "2025-10-06T14:48:50.789Z" }, - { url = "https://files.pythonhosted.org/packages/00/35/f6a637ea2c75f0d3b7c7d41b1189189acff0d9deeb8b8f35536bb30f5e33/multidict-6.7.0-cp310-cp310-win_amd64.whl", hash = "sha256:b15b3afff74f707b9275d5ba6a91ae8f6429c3ffb29bbfd216b0b375a56f13d7", size = 46050, upload-time = "2025-10-06T14:48:51.938Z" }, - { url = "https://files.pythonhosted.org/packages/e7/b8/f7bf8329b39893d02d9d95cf610c75885d12fc0f402b1c894e1c8e01c916/multidict-6.7.0-cp310-cp310-win_arm64.whl", hash = "sha256:4b73189894398d59131a66ff157837b1fafea9974be486d036bb3d32331fdbf0", size = 43153, upload-time = "2025-10-06T14:48:53.146Z" }, - { url = "https://files.pythonhosted.org/packages/34/9e/5c727587644d67b2ed479041e4b1c58e30afc011e3d45d25bbe35781217c/multidict-6.7.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:4d409aa42a94c0b3fa617708ef5276dfe81012ba6753a0370fcc9d0195d0a1fc", size = 76604, upload-time = "2025-10-06T14:48:54.277Z" }, - { url = "https://files.pythonhosted.org/packages/17/e4/67b5c27bd17c085a5ea8f1ec05b8a3e5cba0ca734bfcad5560fb129e70ca/multidict-6.7.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:14c9e076eede3b54c636f8ce1c9c252b5f057c62131211f0ceeec273810c9721", size = 44715, upload-time = "2025-10-06T14:48:55.445Z" }, - { url = "https://files.pythonhosted.org/packages/4d/e1/866a5d77be6ea435711bef2a4291eed11032679b6b28b56b4776ab06ba3e/multidict-6.7.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4c09703000a9d0fa3c3404b27041e574cc7f4df4c6563873246d0e11812a94b6", size = 44332, upload-time = "2025-10-06T14:48:56.706Z" }, - { url = "https://files.pythonhosted.org/packages/31/61/0c2d50241ada71ff61a79518db85ada85fdabfcf395d5968dae1cbda04e5/multidict-6.7.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a265acbb7bb33a3a2d626afbe756371dce0279e7b17f4f4eda406459c2b5ff1c", size = 245212, upload-time = "2025-10-06T14:48:58.042Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e0/919666a4e4b57fff1b57f279be1c9316e6cdc5de8a8b525d76f6598fefc7/multidict-6.7.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51cb455de290ae462593e5b1cb1118c5c22ea7f0d3620d9940bf695cea5a4bd7", size = 246671, upload-time = "2025-10-06T14:49:00.004Z" }, - { url = "https://files.pythonhosted.org/packages/a1/cc/d027d9c5a520f3321b65adea289b965e7bcbd2c34402663f482648c716ce/multidict-6.7.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:db99677b4457c7a5c5a949353e125ba72d62b35f74e26da141530fbb012218a7", size = 225491, upload-time = "2025-10-06T14:49:01.393Z" }, - { url = "https://files.pythonhosted.org/packages/75/c4/bbd633980ce6155a28ff04e6a6492dd3335858394d7bb752d8b108708558/multidict-6.7.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f470f68adc395e0183b92a2f4689264d1ea4b40504a24d9882c27375e6662bb9", size = 257322, upload-time = "2025-10-06T14:49:02.745Z" }, - { url = "https://files.pythonhosted.org/packages/4c/6d/d622322d344f1f053eae47e033b0b3f965af01212de21b10bcf91be991fb/multidict-6.7.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0db4956f82723cc1c270de9c6e799b4c341d327762ec78ef82bb962f79cc07d8", size = 254694, upload-time = "2025-10-06T14:49:04.15Z" }, - { url = "https://files.pythonhosted.org/packages/a8/9f/78f8761c2705d4c6d7516faed63c0ebdac569f6db1bef95e0d5218fdc146/multidict-6.7.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e56d780c238f9e1ae66a22d2adf8d16f485381878250db8d496623cd38b22bd", size = 246715, upload-time = "2025-10-06T14:49:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/78/59/950818e04f91b9c2b95aab3d923d9eabd01689d0dcd889563988e9ea0fd8/multidict-6.7.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d14baca2ee12c1a64740d4531356ba50b82543017f3ad6de0deb943c5979abb", size = 243189, upload-time = "2025-10-06T14:49:07.37Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3d/77c79e1934cad2ee74991840f8a0110966d9599b3af95964c0cd79bb905b/multidict-6.7.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:295a92a76188917c7f99cda95858c822f9e4aae5824246bba9b6b44004ddd0a6", size = 237845, upload-time = "2025-10-06T14:49:08.759Z" }, - { url = "https://files.pythonhosted.org/packages/63/1b/834ce32a0a97a3b70f86437f685f880136677ac00d8bce0027e9fd9c2db7/multidict-6.7.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:39f1719f57adbb767ef592a50ae5ebb794220d1188f9ca93de471336401c34d2", size = 246374, upload-time = "2025-10-06T14:49:10.574Z" }, - { url = "https://files.pythonhosted.org/packages/23/ef/43d1c3ba205b5dec93dc97f3fba179dfa47910fc73aaaea4f7ceb41cec2a/multidict-6.7.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0a13fb8e748dfc94749f622de065dd5c1def7e0d2216dba72b1d8069a389c6ff", size = 253345, upload-time = "2025-10-06T14:49:12.331Z" }, - { url = "https://files.pythonhosted.org/packages/6b/03/eaf95bcc2d19ead522001f6a650ef32811aa9e3624ff0ad37c445c7a588c/multidict-6.7.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e3aa16de190d29a0ea1b48253c57d99a68492c8dd8948638073ab9e74dc9410b", size = 246940, upload-time = "2025-10-06T14:49:13.821Z" }, - { url = "https://files.pythonhosted.org/packages/e8/df/ec8a5fd66ea6cd6f525b1fcbb23511b033c3e9bc42b81384834ffa484a62/multidict-6.7.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a048ce45dcdaaf1defb76b2e684f997fb5abf74437b6cb7b22ddad934a964e34", size = 242229, upload-time = "2025-10-06T14:49:15.603Z" }, - { url = "https://files.pythonhosted.org/packages/8a/a2/59b405d59fd39ec86d1142630e9049243015a5f5291ba49cadf3c090c541/multidict-6.7.0-cp311-cp311-win32.whl", hash = "sha256:a90af66facec4cebe4181b9e62a68be65e45ac9b52b67de9eec118701856e7ff", size = 41308, upload-time = "2025-10-06T14:49:16.871Z" }, - { url = "https://files.pythonhosted.org/packages/32/0f/13228f26f8b882c34da36efa776c3b7348455ec383bab4a66390e42963ae/multidict-6.7.0-cp311-cp311-win_amd64.whl", hash = "sha256:95b5ffa4349df2887518bb839409bcf22caa72d82beec453216802f475b23c81", size = 46037, upload-time = "2025-10-06T14:49:18.457Z" }, - { url = "https://files.pythonhosted.org/packages/84/1f/68588e31b000535a3207fd3c909ebeec4fb36b52c442107499c18a896a2a/multidict-6.7.0-cp311-cp311-win_arm64.whl", hash = "sha256:329aa225b085b6f004a4955271a7ba9f1087e39dcb7e65f6284a988264a63912", size = 43023, upload-time = "2025-10-06T14:49:19.648Z" }, - { url = "https://files.pythonhosted.org/packages/c2/9e/9f61ac18d9c8b475889f32ccfa91c9f59363480613fc807b6e3023d6f60b/multidict-6.7.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:8a3862568a36d26e650a19bb5cbbba14b71789032aebc0423f8cc5f150730184", size = 76877, upload-time = "2025-10-06T14:49:20.884Z" }, - { url = "https://files.pythonhosted.org/packages/38/6f/614f09a04e6184f8824268fce4bc925e9849edfa654ddd59f0b64508c595/multidict-6.7.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:960c60b5849b9b4f9dcc9bea6e3626143c252c74113df2c1540aebce70209b45", size = 45467, upload-time = "2025-10-06T14:49:22.054Z" }, - { url = "https://files.pythonhosted.org/packages/b3/93/c4f67a436dd026f2e780c433277fff72be79152894d9fc36f44569cab1a6/multidict-6.7.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2049be98fb57a31b4ccf870bf377af2504d4ae35646a19037ec271e4c07998aa", size = 43834, upload-time = "2025-10-06T14:49:23.566Z" }, - { url = "https://files.pythonhosted.org/packages/7f/f5/013798161ca665e4a422afbc5e2d9e4070142a9ff8905e482139cd09e4d0/multidict-6.7.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0934f3843a1860dd465d38895c17fce1f1cb37295149ab05cd1b9a03afacb2a7", size = 250545, upload-time = "2025-10-06T14:49:24.882Z" }, - { url = "https://files.pythonhosted.org/packages/71/2f/91dbac13e0ba94669ea5119ba267c9a832f0cb65419aca75549fcf09a3dc/multidict-6.7.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b3e34f3a1b8131ba06f1a73adab24f30934d148afcd5f5de9a73565a4404384e", size = 258305, upload-time = "2025-10-06T14:49:26.778Z" }, - { url = "https://files.pythonhosted.org/packages/ef/b0/754038b26f6e04488b48ac621f779c341338d78503fb45403755af2df477/multidict-6.7.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:efbb54e98446892590dc2458c19c10344ee9a883a79b5cec4bc34d6656e8d546", size = 242363, upload-time = "2025-10-06T14:49:28.562Z" }, - { url = "https://files.pythonhosted.org/packages/87/15/9da40b9336a7c9fa606c4cf2ed80a649dffeb42b905d4f63a1d7eb17d746/multidict-6.7.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a35c5fc61d4f51eb045061e7967cfe3123d622cd500e8868e7c0c592a09fedc4", size = 268375, upload-time = "2025-10-06T14:49:29.96Z" }, - { url = "https://files.pythonhosted.org/packages/82/72/c53fcade0cc94dfaad583105fd92b3a783af2091eddcb41a6d5a52474000/multidict-6.7.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29fe6740ebccba4175af1b9b87bf553e9c15cd5868ee967e010efcf94e4fd0f1", size = 269346, upload-time = "2025-10-06T14:49:31.404Z" }, - { url = "https://files.pythonhosted.org/packages/0d/e2/9baffdae21a76f77ef8447f1a05a96ec4bc0a24dae08767abc0a2fe680b8/multidict-6.7.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:123e2a72e20537add2f33a79e605f6191fba2afda4cbb876e35c1a7074298a7d", size = 256107, upload-time = "2025-10-06T14:49:32.974Z" }, - { url = "https://files.pythonhosted.org/packages/3c/06/3f06f611087dc60d65ef775f1fb5aca7c6d61c6db4990e7cda0cef9b1651/multidict-6.7.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b284e319754366c1aee2267a2036248b24eeb17ecd5dc16022095e747f2f4304", size = 253592, upload-time = "2025-10-06T14:49:34.52Z" }, - { url = "https://files.pythonhosted.org/packages/20/24/54e804ec7945b6023b340c412ce9c3f81e91b3bf5fa5ce65558740141bee/multidict-6.7.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:803d685de7be4303b5a657b76e2f6d1240e7e0a8aa2968ad5811fa2285553a12", size = 251024, upload-time = "2025-10-06T14:49:35.956Z" }, - { url = "https://files.pythonhosted.org/packages/14/48/011cba467ea0b17ceb938315d219391d3e421dfd35928e5dbdc3f4ae76ef/multidict-6.7.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c04a328260dfd5db8c39538f999f02779012268f54614902d0afc775d44e0a62", size = 251484, upload-time = "2025-10-06T14:49:37.631Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2f/919258b43bb35b99fa127435cfb2d91798eb3a943396631ef43e3720dcf4/multidict-6.7.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8a19cdb57cd3df4cd865849d93ee14920fb97224300c88501f16ecfa2604b4e0", size = 263579, upload-time = "2025-10-06T14:49:39.502Z" }, - { url = "https://files.pythonhosted.org/packages/31/22/a0e884d86b5242b5a74cf08e876bdf299e413016b66e55511f7a804a366e/multidict-6.7.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:9b2fd74c52accced7e75de26023b7dccee62511a600e62311b918ec5c168fc2a", size = 259654, upload-time = "2025-10-06T14:49:41.32Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e5/17e10e1b5c5f5a40f2fcbb45953c9b215f8a4098003915e46a93f5fcaa8f/multidict-6.7.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3e8bfdd0e487acf992407a140d2589fe598238eaeffa3da8448d63a63cd363f8", size = 251511, upload-time = "2025-10-06T14:49:46.021Z" }, - { url = "https://files.pythonhosted.org/packages/e3/9a/201bb1e17e7af53139597069c375e7b0dcbd47594604f65c2d5359508566/multidict-6.7.0-cp312-cp312-win32.whl", hash = "sha256:dd32a49400a2c3d52088e120ee00c1e3576cbff7e10b98467962c74fdb762ed4", size = 41895, upload-time = "2025-10-06T14:49:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/46/e2/348cd32faad84eaf1d20cce80e2bb0ef8d312c55bca1f7fa9865e7770aaf/multidict-6.7.0-cp312-cp312-win_amd64.whl", hash = "sha256:92abb658ef2d7ef22ac9f8bb88e8b6c3e571671534e029359b6d9e845923eb1b", size = 46073, upload-time = "2025-10-06T14:49:50.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ec/aad2613c1910dce907480e0c3aa306905830f25df2e54ccc9dea450cb5aa/multidict-6.7.0-cp312-cp312-win_arm64.whl", hash = "sha256:490dab541a6a642ce1a9d61a4781656b346a55c13038f0b1244653828e3a83ec", size = 43226, upload-time = "2025-10-06T14:49:52.304Z" }, - { url = "https://files.pythonhosted.org/packages/d2/86/33272a544eeb36d66e4d9a920602d1a2f57d4ebea4ef3cdfe5a912574c95/multidict-6.7.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:bee7c0588aa0076ce77c0ea5d19a68d76ad81fcd9fe8501003b9a24f9d4000f6", size = 76135, upload-time = "2025-10-06T14:49:54.26Z" }, - { url = "https://files.pythonhosted.org/packages/91/1c/eb97db117a1ebe46d457a3d235a7b9d2e6dcab174f42d1b67663dd9e5371/multidict-6.7.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7ef6b61cad77091056ce0e7ce69814ef72afacb150b7ac6a3e9470def2198159", size = 45117, upload-time = "2025-10-06T14:49:55.82Z" }, - { url = "https://files.pythonhosted.org/packages/f1/d8/6c3442322e41fb1dd4de8bd67bfd11cd72352ac131f6368315617de752f1/multidict-6.7.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9c0359b1ec12b1d6849c59f9d319610b7f20ef990a6d454ab151aa0e3b9f78ca", size = 43472, upload-time = "2025-10-06T14:49:57.048Z" }, - { url = "https://files.pythonhosted.org/packages/75/3f/e2639e80325af0b6c6febdf8e57cc07043ff15f57fa1ef808f4ccb5ac4cd/multidict-6.7.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cd240939f71c64bd658f186330603aac1a9a81bf6273f523fca63673cb7378a8", size = 249342, upload-time = "2025-10-06T14:49:58.368Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cc/84e0585f805cbeaa9cbdaa95f9a3d6aed745b9d25700623ac89a6ecff400/multidict-6.7.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a60a4d75718a5efa473ebd5ab685786ba0c67b8381f781d1be14da49f1a2dc60", size = 257082, upload-time = "2025-10-06T14:49:59.89Z" }, - { url = "https://files.pythonhosted.org/packages/b0/9c/ac851c107c92289acbbf5cfb485694084690c1b17e555f44952c26ddc5bd/multidict-6.7.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53a42d364f323275126aff81fb67c5ca1b7a04fda0546245730a55c8c5f24bc4", size = 240704, upload-time = "2025-10-06T14:50:01.485Z" }, - { url = "https://files.pythonhosted.org/packages/50/cc/5f93e99427248c09da95b62d64b25748a5f5c98c7c2ab09825a1d6af0e15/multidict-6.7.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3b29b980d0ddbecb736735ee5bef69bb2ddca56eff603c86f3f29a1128299b4f", size = 266355, upload-time = "2025-10-06T14:50:02.955Z" }, - { url = "https://files.pythonhosted.org/packages/ec/0c/2ec1d883ceb79c6f7f6d7ad90c919c898f5d1c6ea96d322751420211e072/multidict-6.7.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f8a93b1c0ed2d04b97a5e9336fd2d33371b9a6e29ab7dd6503d63407c20ffbaf", size = 267259, upload-time = "2025-10-06T14:50:04.446Z" }, - { url = "https://files.pythonhosted.org/packages/c6/2d/f0b184fa88d6630aa267680bdb8623fb69cb0d024b8c6f0d23f9a0f406d3/multidict-6.7.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9ff96e8815eecacc6645da76c413eb3b3d34cfca256c70b16b286a687d013c32", size = 254903, upload-time = "2025-10-06T14:50:05.98Z" }, - { url = "https://files.pythonhosted.org/packages/06/c9/11ea263ad0df7dfabcad404feb3c0dd40b131bc7f232d5537f2fb1356951/multidict-6.7.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7516c579652f6a6be0e266aec0acd0db80829ca305c3d771ed898538804c2036", size = 252365, upload-time = "2025-10-06T14:50:07.511Z" }, - { url = "https://files.pythonhosted.org/packages/41/88/d714b86ee2c17d6e09850c70c9d310abac3d808ab49dfa16b43aba9d53fd/multidict-6.7.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:040f393368e63fb0f3330e70c26bfd336656bed925e5cbe17c9da839a6ab13ec", size = 250062, upload-time = "2025-10-06T14:50:09.074Z" }, - { url = "https://files.pythonhosted.org/packages/15/fe/ad407bb9e818c2b31383f6131ca19ea7e35ce93cf1310fce69f12e89de75/multidict-6.7.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b3bc26a951007b1057a1c543af845f1c7e3e71cc240ed1ace7bf4484aa99196e", size = 249683, upload-time = "2025-10-06T14:50:10.714Z" }, - { url = "https://files.pythonhosted.org/packages/8c/a4/a89abdb0229e533fb925e7c6e5c40201c2873efebc9abaf14046a4536ee6/multidict-6.7.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7b022717c748dd1992a83e219587aabe45980d88969f01b316e78683e6285f64", size = 261254, upload-time = "2025-10-06T14:50:12.28Z" }, - { url = "https://files.pythonhosted.org/packages/8d/aa/0e2b27bd88b40a4fb8dc53dd74eecac70edaa4c1dd0707eb2164da3675b3/multidict-6.7.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:9600082733859f00d79dee64effc7aef1beb26adb297416a4ad2116fd61374bd", size = 257967, upload-time = "2025-10-06T14:50:14.16Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8e/0c67b7120d5d5f6d874ed85a085f9dc770a7f9d8813e80f44a9fec820bb7/multidict-6.7.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:94218fcec4d72bc61df51c198d098ce2b378e0ccbac41ddbed5ef44092913288", size = 250085, upload-time = "2025-10-06T14:50:15.639Z" }, - { url = "https://files.pythonhosted.org/packages/ba/55/b73e1d624ea4b8fd4dd07a3bb70f6e4c7c6c5d9d640a41c6ffe5cdbd2a55/multidict-6.7.0-cp313-cp313-win32.whl", hash = "sha256:a37bd74c3fa9d00be2d7b8eca074dc56bd8077ddd2917a839bd989612671ed17", size = 41713, upload-time = "2025-10-06T14:50:17.066Z" }, - { url = "https://files.pythonhosted.org/packages/32/31/75c59e7d3b4205075b4c183fa4ca398a2daf2303ddf616b04ae6ef55cffe/multidict-6.7.0-cp313-cp313-win_amd64.whl", hash = "sha256:30d193c6cc6d559db42b6bcec8a5d395d34d60c9877a0b71ecd7c204fcf15390", size = 45915, upload-time = "2025-10-06T14:50:18.264Z" }, - { url = "https://files.pythonhosted.org/packages/31/2a/8987831e811f1184c22bc2e45844934385363ee61c0a2dcfa8f71b87e608/multidict-6.7.0-cp313-cp313-win_arm64.whl", hash = "sha256:ea3334cabe4d41b7ccd01e4d349828678794edbc2d3ae97fc162a3312095092e", size = 43077, upload-time = "2025-10-06T14:50:19.853Z" }, - { url = "https://files.pythonhosted.org/packages/e8/68/7b3a5170a382a340147337b300b9eb25a9ddb573bcdfff19c0fa3f31ffba/multidict-6.7.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ad9ce259f50abd98a1ca0aa6e490b58c316a0fce0617f609723e40804add2c00", size = 83114, upload-time = "2025-10-06T14:50:21.223Z" }, - { url = "https://files.pythonhosted.org/packages/55/5c/3fa2d07c84df4e302060f555bbf539310980362236ad49f50eeb0a1c1eb9/multidict-6.7.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07f5594ac6d084cbb5de2df218d78baf55ef150b91f0ff8a21cc7a2e3a5a58eb", size = 48442, upload-time = "2025-10-06T14:50:22.871Z" }, - { url = "https://files.pythonhosted.org/packages/fc/56/67212d33239797f9bd91962bb899d72bb0f4c35a8652dcdb8ed049bef878/multidict-6.7.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:0591b48acf279821a579282444814a2d8d0af624ae0bc600aa4d1b920b6e924b", size = 46885, upload-time = "2025-10-06T14:50:24.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/d1/908f896224290350721597a61a69cd19b89ad8ee0ae1f38b3f5cd12ea2ac/multidict-6.7.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:749a72584761531d2b9467cfbdfd29487ee21124c304c4b6cb760d8777b27f9c", size = 242588, upload-time = "2025-10-06T14:50:25.716Z" }, - { url = "https://files.pythonhosted.org/packages/ab/67/8604288bbd68680eee0ab568fdcb56171d8b23a01bcd5cb0c8fedf6e5d99/multidict-6.7.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b4c3d199f953acd5b446bf7c0de1fe25d94e09e79086f8dc2f48a11a129cdf1", size = 249966, upload-time = "2025-10-06T14:50:28.192Z" }, - { url = "https://files.pythonhosted.org/packages/20/33/9228d76339f1ba51e3efef7da3ebd91964d3006217aae13211653193c3ff/multidict-6.7.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9fb0211dfc3b51efea2f349ec92c114d7754dd62c01f81c3e32b765b70c45c9b", size = 228618, upload-time = "2025-10-06T14:50:29.82Z" }, - { url = "https://files.pythonhosted.org/packages/f8/2d/25d9b566d10cab1c42b3b9e5b11ef79c9111eaf4463b8c257a3bd89e0ead/multidict-6.7.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a027ec240fe73a8d6281872690b988eed307cd7d91b23998ff35ff577ca688b5", size = 257539, upload-time = "2025-10-06T14:50:31.731Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b1/8d1a965e6637fc33de3c0d8f414485c2b7e4af00f42cab3d84e7b955c222/multidict-6.7.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1d964afecdf3a8288789df2f5751dc0a8261138c3768d9af117ed384e538fad", size = 256345, upload-time = "2025-10-06T14:50:33.26Z" }, - { url = "https://files.pythonhosted.org/packages/ba/0c/06b5a8adbdeedada6f4fb8d8f193d44a347223b11939b42953eeb6530b6b/multidict-6.7.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:caf53b15b1b7df9fbd0709aa01409000a2b4dd03a5f6f5cc548183c7c8f8b63c", size = 247934, upload-time = "2025-10-06T14:50:34.808Z" }, - { url = "https://files.pythonhosted.org/packages/8f/31/b2491b5fe167ca044c6eb4b8f2c9f3b8a00b24c432c365358eadac5d7625/multidict-6.7.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:654030da3197d927f05a536a66186070e98765aa5142794c9904555d3a9d8fb5", size = 245243, upload-time = "2025-10-06T14:50:36.436Z" }, - { url = "https://files.pythonhosted.org/packages/61/1a/982913957cb90406c8c94f53001abd9eafc271cb3e70ff6371590bec478e/multidict-6.7.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:2090d3718829d1e484706a2f525e50c892237b2bf9b17a79b059cb98cddc2f10", size = 235878, upload-time = "2025-10-06T14:50:37.953Z" }, - { url = "https://files.pythonhosted.org/packages/be/c0/21435d804c1a1cf7a2608593f4d19bca5bcbd7a81a70b253fdd1c12af9c0/multidict-6.7.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2d2cfeec3f6f45651b3d408c4acec0ebf3daa9bc8a112a084206f5db5d05b754", size = 243452, upload-time = "2025-10-06T14:50:39.574Z" }, - { url = "https://files.pythonhosted.org/packages/54/0a/4349d540d4a883863191be6eb9a928846d4ec0ea007d3dcd36323bb058ac/multidict-6.7.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef089f985b8c194d341eb2c24ae6e7408c9a0e2e5658699c92f497437d88c3c", size = 252312, upload-time = "2025-10-06T14:50:41.612Z" }, - { url = "https://files.pythonhosted.org/packages/26/64/d5416038dbda1488daf16b676e4dbfd9674dde10a0cc8f4fc2b502d8125d/multidict-6.7.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e93a0617cd16998784bf4414c7e40f17a35d2350e5c6f0bd900d3a8e02bd3762", size = 246935, upload-time = "2025-10-06T14:50:43.972Z" }, - { url = "https://files.pythonhosted.org/packages/9f/8c/8290c50d14e49f35e0bd4abc25e1bc7711149ca9588ab7d04f886cdf03d9/multidict-6.7.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f0feece2ef8ebc42ed9e2e8c78fc4aa3cf455733b507c09ef7406364c94376c6", size = 243385, upload-time = "2025-10-06T14:50:45.648Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a0/f83ae75e42d694b3fbad3e047670e511c138be747bc713cf1b10d5096416/multidict-6.7.0-cp313-cp313t-win32.whl", hash = "sha256:19a1d55338ec1be74ef62440ca9e04a2f001a04d0cc49a4983dc320ff0f3212d", size = 47777, upload-time = "2025-10-06T14:50:47.154Z" }, - { url = "https://files.pythonhosted.org/packages/dc/80/9b174a92814a3830b7357307a792300f42c9e94664b01dee8e457551fa66/multidict-6.7.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3da4fb467498df97e986af166b12d01f05d2e04f978a9c1c680ea1988e0bc4b6", size = 53104, upload-time = "2025-10-06T14:50:48.851Z" }, - { url = "https://files.pythonhosted.org/packages/cc/28/04baeaf0428d95bb7a7bea0e691ba2f31394338ba424fb0679a9ed0f4c09/multidict-6.7.0-cp313-cp313t-win_arm64.whl", hash = "sha256:b4121773c49a0776461f4a904cdf6264c88e42218aaa8407e803ca8025872792", size = 45503, upload-time = "2025-10-06T14:50:50.16Z" }, - { url = "https://files.pythonhosted.org/packages/e2/b1/3da6934455dd4b261d4c72f897e3a5728eba81db59959f3a639245891baa/multidict-6.7.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3bab1e4aff7adaa34410f93b1f8e57c4b36b9af0426a76003f441ee1d3c7e842", size = 75128, upload-time = "2025-10-06T14:50:51.92Z" }, - { url = "https://files.pythonhosted.org/packages/14/2c/f069cab5b51d175a1a2cb4ccdf7a2c2dabd58aa5bd933fa036a8d15e2404/multidict-6.7.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b8512bac933afc3e45fb2b18da8e59b78d4f408399a960339598374d4ae3b56b", size = 44410, upload-time = "2025-10-06T14:50:53.275Z" }, - { url = "https://files.pythonhosted.org/packages/42/e2/64bb41266427af6642b6b128e8774ed84c11b80a90702c13ac0a86bb10cc/multidict-6.7.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:79dcf9e477bc65414ebfea98ffd013cb39552b5ecd62908752e0e413d6d06e38", size = 43205, upload-time = "2025-10-06T14:50:54.911Z" }, - { url = "https://files.pythonhosted.org/packages/02/68/6b086fef8a3f1a8541b9236c594f0c9245617c29841f2e0395d979485cde/multidict-6.7.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:31bae522710064b5cbeddaf2e9f32b1abab70ac6ac91d42572502299e9953128", size = 245084, upload-time = "2025-10-06T14:50:56.369Z" }, - { url = "https://files.pythonhosted.org/packages/15/ee/f524093232007cd7a75c1d132df70f235cfd590a7c9eaccd7ff422ef4ae8/multidict-6.7.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a0df7ff02397bb63e2fd22af2c87dfa39e8c7f12947bc524dbdc528282c7e34", size = 252667, upload-time = "2025-10-06T14:50:57.991Z" }, - { url = "https://files.pythonhosted.org/packages/02/a5/eeb3f43ab45878f1895118c3ef157a480db58ede3f248e29b5354139c2c9/multidict-6.7.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a0222514e8e4c514660e182d5156a415c13ef0aabbd71682fc714e327b95e99", size = 233590, upload-time = "2025-10-06T14:50:59.589Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/76d02f8270b97269d7e3dbd45644b1785bda457b474315f8cf999525a193/multidict-6.7.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2397ab4daaf2698eb51a76721e98db21ce4f52339e535725de03ea962b5a3202", size = 264112, upload-time = "2025-10-06T14:51:01.183Z" }, - { url = "https://files.pythonhosted.org/packages/76/0b/c28a70ecb58963847c2a8efe334904cd254812b10e535aefb3bcce513918/multidict-6.7.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8891681594162635948a636c9fe0ff21746aeb3dd5463f6e25d9bea3a8a39ca1", size = 261194, upload-time = "2025-10-06T14:51:02.794Z" }, - { url = "https://files.pythonhosted.org/packages/b4/63/2ab26e4209773223159b83aa32721b4021ffb08102f8ac7d689c943fded1/multidict-6.7.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18706cc31dbf402a7945916dd5cddf160251b6dab8a2c5f3d6d5a55949f676b3", size = 248510, upload-time = "2025-10-06T14:51:04.724Z" }, - { url = "https://files.pythonhosted.org/packages/93/cd/06c1fa8282af1d1c46fd55c10a7930af652afdce43999501d4d68664170c/multidict-6.7.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f844a1bbf1d207dd311a56f383f7eda2d0e134921d45751842d8235e7778965d", size = 248395, upload-time = "2025-10-06T14:51:06.306Z" }, - { url = "https://files.pythonhosted.org/packages/99/ac/82cb419dd6b04ccf9e7e61befc00c77614fc8134362488b553402ecd55ce/multidict-6.7.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4393e3581e84e5645506923816b9cc81f5609a778c7e7534054091acc64d1c6", size = 239520, upload-time = "2025-10-06T14:51:08.091Z" }, - { url = "https://files.pythonhosted.org/packages/fa/f3/a0f9bf09493421bd8716a362e0cd1d244f5a6550f5beffdd6b47e885b331/multidict-6.7.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:fbd18dc82d7bf274b37aa48d664534330af744e03bccf696d6f4c6042e7d19e7", size = 245479, upload-time = "2025-10-06T14:51:10.365Z" }, - { url = "https://files.pythonhosted.org/packages/8d/01/476d38fc73a212843f43c852b0eee266b6971f0e28329c2184a8df90c376/multidict-6.7.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b6234e14f9314731ec45c42fc4554b88133ad53a09092cc48a88e771c125dadb", size = 258903, upload-time = "2025-10-06T14:51:12.466Z" }, - { url = "https://files.pythonhosted.org/packages/49/6d/23faeb0868adba613b817d0e69c5f15531b24d462af8012c4f6de4fa8dc3/multidict-6.7.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:08d4379f9744d8f78d98c8673c06e202ffa88296f009c71bbafe8a6bf847d01f", size = 252333, upload-time = "2025-10-06T14:51:14.48Z" }, - { url = "https://files.pythonhosted.org/packages/1e/cc/48d02ac22b30fa247f7dad82866e4b1015431092f4ba6ebc7e77596e0b18/multidict-6.7.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9fe04da3f79387f450fd0061d4dd2e45a72749d31bf634aecc9e27f24fdc4b3f", size = 243411, upload-time = "2025-10-06T14:51:16.072Z" }, - { url = "https://files.pythonhosted.org/packages/4a/03/29a8bf5a18abf1fe34535c88adbdfa88c9fb869b5a3b120692c64abe8284/multidict-6.7.0-cp314-cp314-win32.whl", hash = "sha256:fbafe31d191dfa7c4c51f7a6149c9fb7e914dcf9ffead27dcfd9f1ae382b3885", size = 40940, upload-time = "2025-10-06T14:51:17.544Z" }, - { url = "https://files.pythonhosted.org/packages/82/16/7ed27b680791b939de138f906d5cf2b4657b0d45ca6f5dd6236fdddafb1a/multidict-6.7.0-cp314-cp314-win_amd64.whl", hash = "sha256:2f67396ec0310764b9222a1728ced1ab638f61aadc6226f17a71dd9324f9a99c", size = 45087, upload-time = "2025-10-06T14:51:18.875Z" }, - { url = "https://files.pythonhosted.org/packages/cd/3c/e3e62eb35a1950292fe39315d3c89941e30a9d07d5d2df42965ab041da43/multidict-6.7.0-cp314-cp314-win_arm64.whl", hash = "sha256:ba672b26069957ee369cfa7fc180dde1fc6f176eaf1e6beaf61fbebbd3d9c000", size = 42368, upload-time = "2025-10-06T14:51:20.225Z" }, - { url = "https://files.pythonhosted.org/packages/8b/40/cd499bd0dbc5f1136726db3153042a735fffd0d77268e2ee20d5f33c010f/multidict-6.7.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:c1dcc7524066fa918c6a27d61444d4ee7900ec635779058571f70d042d86ed63", size = 82326, upload-time = "2025-10-06T14:51:21.588Z" }, - { url = "https://files.pythonhosted.org/packages/13/8a/18e031eca251c8df76daf0288e6790561806e439f5ce99a170b4af30676b/multidict-6.7.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:27e0b36c2d388dc7b6ced3406671b401e84ad7eb0656b8f3a2f46ed0ce483718", size = 48065, upload-time = "2025-10-06T14:51:22.93Z" }, - { url = "https://files.pythonhosted.org/packages/40/71/5e6701277470a87d234e433fb0a3a7deaf3bcd92566e421e7ae9776319de/multidict-6.7.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2a7baa46a22e77f0988e3b23d4ede5513ebec1929e34ee9495be535662c0dfe2", size = 46475, upload-time = "2025-10-06T14:51:24.352Z" }, - { url = "https://files.pythonhosted.org/packages/fe/6a/bab00cbab6d9cfb57afe1663318f72ec28289ea03fd4e8236bb78429893a/multidict-6.7.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bf77f54997a9166a2f5675d1201520586439424c2511723a7312bdb4bcc034e", size = 239324, upload-time = "2025-10-06T14:51:25.822Z" }, - { url = "https://files.pythonhosted.org/packages/2a/5f/8de95f629fc22a7769ade8b41028e3e5a822c1f8904f618d175945a81ad3/multidict-6.7.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e011555abada53f1578d63389610ac8a5400fc70ce71156b0aa30d326f1a5064", size = 246877, upload-time = "2025-10-06T14:51:27.604Z" }, - { url = "https://files.pythonhosted.org/packages/23/b4/38881a960458f25b89e9f4a4fdcb02ac101cfa710190db6e5528841e67de/multidict-6.7.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:28b37063541b897fd6a318007373930a75ca6d6ac7c940dbe14731ffdd8d498e", size = 225824, upload-time = "2025-10-06T14:51:29.664Z" }, - { url = "https://files.pythonhosted.org/packages/1e/39/6566210c83f8a261575f18e7144736059f0c460b362e96e9cf797a24b8e7/multidict-6.7.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05047ada7a2fde2631a0ed706f1fd68b169a681dfe5e4cf0f8e4cb6618bbc2cd", size = 253558, upload-time = "2025-10-06T14:51:31.684Z" }, - { url = "https://files.pythonhosted.org/packages/00/a3/67f18315100f64c269f46e6c0319fa87ba68f0f64f2b8e7fd7c72b913a0b/multidict-6.7.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:716133f7d1d946a4e1b91b1756b23c088881e70ff180c24e864c26192ad7534a", size = 252339, upload-time = "2025-10-06T14:51:33.699Z" }, - { url = "https://files.pythonhosted.org/packages/c8/2a/1cb77266afee2458d82f50da41beba02159b1d6b1f7973afc9a1cad1499b/multidict-6.7.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d1bed1b467ef657f2a0ae62844a607909ef1c6889562de5e1d505f74457d0b96", size = 244895, upload-time = "2025-10-06T14:51:36.189Z" }, - { url = "https://files.pythonhosted.org/packages/dd/72/09fa7dd487f119b2eb9524946ddd36e2067c08510576d43ff68469563b3b/multidict-6.7.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca43bdfa5d37bd6aee89d85e1d0831fb86e25541be7e9d376ead1b28974f8e5e", size = 241862, upload-time = "2025-10-06T14:51:41.291Z" }, - { url = "https://files.pythonhosted.org/packages/65/92/bc1f8bd0853d8669300f732c801974dfc3702c3eeadae2f60cef54dc69d7/multidict-6.7.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:44b546bd3eb645fd26fb949e43c02a25a2e632e2ca21a35e2e132c8105dc8599", size = 232376, upload-time = "2025-10-06T14:51:43.55Z" }, - { url = "https://files.pythonhosted.org/packages/09/86/ac39399e5cb9d0c2ac8ef6e10a768e4d3bc933ac808d49c41f9dc23337eb/multidict-6.7.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a6ef16328011d3f468e7ebc326f24c1445f001ca1dec335b2f8e66bed3006394", size = 240272, upload-time = "2025-10-06T14:51:45.265Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b6/fed5ac6b8563ec72df6cb1ea8dac6d17f0a4a1f65045f66b6d3bf1497c02/multidict-6.7.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5aa873cbc8e593d361ae65c68f85faadd755c3295ea2c12040ee146802f23b38", size = 248774, upload-time = "2025-10-06T14:51:46.836Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8d/b954d8c0dc132b68f760aefd45870978deec6818897389dace00fcde32ff/multidict-6.7.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:3d7b6ccce016e29df4b7ca819659f516f0bc7a4b3efa3bb2012ba06431b044f9", size = 242731, upload-time = "2025-10-06T14:51:48.541Z" }, - { url = "https://files.pythonhosted.org/packages/16/9d/a2dac7009125d3540c2f54e194829ea18ac53716c61b655d8ed300120b0f/multidict-6.7.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:171b73bd4ee683d307599b66793ac80981b06f069b62eea1c9e29c9241aa66b0", size = 240193, upload-time = "2025-10-06T14:51:50.355Z" }, - { url = "https://files.pythonhosted.org/packages/39/ca/c05f144128ea232ae2178b008d5011d4e2cea86e4ee8c85c2631b1b94802/multidict-6.7.0-cp314-cp314t-win32.whl", hash = "sha256:b2d7f80c4e1fd010b07cb26820aae86b7e73b681ee4889684fb8d2d4537aab13", size = 48023, upload-time = "2025-10-06T14:51:51.883Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8f/0a60e501584145588be1af5cc829265701ba3c35a64aec8e07cbb71d39bb/multidict-6.7.0-cp314-cp314t-win_amd64.whl", hash = "sha256:09929cab6fcb68122776d575e03c6cc64ee0b8fca48d17e135474b042ce515cd", size = 53507, upload-time = "2025-10-06T14:51:53.672Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ae/3148b988a9c6239903e786eac19c889fab607c31d6efa7fb2147e5680f23/multidict-6.7.0-cp314-cp314t-win_arm64.whl", hash = "sha256:cc41db090ed742f32bd2d2c721861725e6109681eddf835d0a82bd3a5c382827", size = 44804, upload-time = "2025-10-06T14:51:55.415Z" }, - { url = "https://files.pythonhosted.org/packages/b7/da/7d22601b625e241d4f23ef1ebff8acfc60da633c9e7e7922e24d10f592b3/multidict-6.7.0-py3-none-any.whl", hash = "sha256:394fc5c42a333c9ffc3e421a4c85e08580d990e08b99f6bf35b4132114c5dcb3", size = 12317, upload-time = "2025-10-06T14:52:29.272Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d6/99/1d4d69c3512d0ddbfa3a1b69cfd9a151012ab2eb4eabbb096201b1f0b7d8/multidict-6.9.1.tar.gz", hash = "sha256:0f06e60fa190aa7abd0914c2a766736fdc8e9f34878c4346338534b73d1b20e2", size = 182404, upload-time = "2026-09-21T17:59:05.362Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/1c/fb2114207afc7f00ab9897ae5beeee27de5ec58ea8b9623e879f2c56f003/multidict-6.9.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:aef74e9beabbd6c4aafc091dabff86d046ccf013ce1e4396c0fbb01b4cad9de8", size = 97307, upload-time = "2026-09-21T17:54:14.957Z" }, + { url = "https://files.pythonhosted.org/packages/b7/23/1ab8c7349adbb30c8cf41b88b0794740df8ea9f7de2e382da040a2f5bd8e/multidict-6.9.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1ca5ebe6d454f1e5496cf052386a559f1080bf2de75bf327ebca0a6003b79f19", size = 58830, upload-time = "2026-09-21T17:54:16.261Z" }, + { url = "https://files.pythonhosted.org/packages/e0/89/b845535014a9ad50537ca50204aaf6351c5368822a3acd9da854fb2a5a34/multidict-6.9.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02f6d0c4b70f783305e73f9944d8efe6be1022550f0974ba0ae9d8893c0350fa", size = 57645, upload-time = "2026-09-21T17:54:17.491Z" }, + { url = "https://files.pythonhosted.org/packages/fe/4f/28ebb40a6f8e8aa7be78bcbe4bf9a1ac9518c40ef88accf2d4cd6ed7a7e0/multidict-6.9.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8050e75af7e4c6e2d5260b84eeedb618f3e452e66473432e085b5d1b81429299", size = 301535, upload-time = "2026-09-21T17:54:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/33/51/0de5fc980df34d1ffed9ef68f60ce86c2d4baa3d422598a53f8730c56757/multidict-6.9.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4736d350371337825cac1793c9f7c40701c32a03912548c2a7608e51679cbb96", size = 298796, upload-time = "2026-09-21T17:54:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/55/58/dd86067bdd2e961fdee8ec659fabc50f4e76403616b508714ec2686b0a49/multidict-6.9.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:02fe09dc197b8ae7e355371e51e5dce2f39060cd5a8badf94904527e23a3f188", size = 282392, upload-time = "2026-09-21T17:54:21.86Z" }, + { url = "https://files.pythonhosted.org/packages/2f/52/5464a2675579e3adb48b7cd4707a59da499660f0e22c024bc7281d2d9bdf/multidict-6.9.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ad4528cdce058b684f75fad1faf4a6a6c992fe2f08376370ca66e4ce5916a84a", size = 314538, upload-time = "2026-09-21T17:54:23.366Z" }, + { url = "https://files.pythonhosted.org/packages/d3/69/7f2b3fafe064181669157ce872a097b08a0546fb6fd2481e8c5604b47ec3/multidict-6.9.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8885e3808aedbd6725b921fb67dacaae0678933561ddd47c2b01315198c70e2e", size = 315675, upload-time = "2026-09-21T17:54:24.808Z" }, + { url = "https://files.pythonhosted.org/packages/bd/53/f65ed68b96eea2b4f6fa4f9031bdfccd8f3097c429e3d371c545d1c40ca4/multidict-6.9.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3b6e6840421c83ccb60398e333b44f910b0907bb409597685ab2eedd1e22eab", size = 303685, upload-time = "2026-09-21T17:54:26.236Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5f/ade05795c6fb9adfccd61695a0f0509bc793583f589c4b1b87f27215a139/multidict-6.9.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c9648ed33dc8179e4ec04bbc73bd7f0038e1e81217a69467f61f02a78bf07e88", size = 280306, upload-time = "2026-09-21T17:54:27.685Z" }, + { url = "https://files.pythonhosted.org/packages/60/b2/c996349a61e0f43c7c95877943a67476261bf906ff993670afbd33ff5bf4/multidict-6.9.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d9d6544790ba50438a9c1a903c3c4afb1ec8a7832db5518549275b40ef0dd4b1", size = 291691, upload-time = "2026-09-21T17:54:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/0e/87/8b7016d1084854729c80acfae71c5a51ea7e7c9411d66c3e2729772f5723/multidict-6.9.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1c9f3c25df6c9d3bbae4f6fd3f514b1c5c740a2110f56ccf36069c85417e289c", size = 290357, upload-time = "2026-09-21T17:54:30.491Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3d/801653b6aa9343a7430cecdefa1bf71ff24432224c02f15cc15e47837652/multidict-6.9.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:cf22b43f35b7dbb9f71e8ee2041b5c00029cdfc28ede3a2f31cf8906f9a6c126", size = 305831, upload-time = "2026-09-21T17:54:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f3/9fc997861920bdbf70db82bdce5c802ec6477754c4bd78cff2898e3c63f9/multidict-6.9.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8ed78ccad4c7b421804f5d524b7740946a7ef75d89dc0dac52e9d7c24c472410", size = 308995, upload-time = "2026-09-21T17:54:33.571Z" }, + { url = "https://files.pythonhosted.org/packages/ce/5f/278b5a2c99d6de27914b49158dd6f2a6481e53359142cc366dff04821a2f/multidict-6.9.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3b1aa25f01238886a6baed9e13b9a9240ed344346c79ae280ae65e8603b21d5", size = 279240, upload-time = "2026-09-21T17:54:35.025Z" }, + { url = "https://files.pythonhosted.org/packages/3d/db/0922594c601a691eb4365a83f4f92104ef87a9728d8066fa25c99349e754/multidict-6.9.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:bc94a68ea5e18f8e85dc6b522bcb53093f692c8eb62b4837ac047da73956cbe4", size = 305487, upload-time = "2026-09-21T17:54:36.555Z" }, + { url = "https://files.pythonhosted.org/packages/64/cb/07bca86d4a361a87128f3019540d7689800fdcad44e3b3ad08f1f035ed74/multidict-6.9.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:73533644e1f69ea1164d56cc6505f564c6c44072b646b66d67df2d31fed0348c", size = 301997, upload-time = "2026-09-21T17:54:37.903Z" }, + { url = "https://files.pythonhosted.org/packages/e1/e3/99591bf626613ea35779d2460949b45e59e589dc9058d6693c923eccd4e5/multidict-6.9.1-cp310-cp310-win32.whl", hash = "sha256:66987aa68b0f7c2a1cc5f388ca962b8ed92b10f79de38d6d0d8c716154f519d9", size = 51205, upload-time = "2026-09-21T17:54:39.152Z" }, + { url = "https://files.pythonhosted.org/packages/ae/18/10c008aac77297aa618631e8726ece31668f9dc9d9c848e4b5d2e68eb3d3/multidict-6.9.1-cp310-cp310-win_amd64.whl", hash = "sha256:a32b78c1e52ebd8e247bb68300b90b233300d8816faa008ed0713bc539fb6af0", size = 59049, upload-time = "2026-09-21T17:54:40.465Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b1/da73a58bef2613571902a824f1e11185207ba756215c99038f6d220bc167/multidict-6.9.1-cp310-cp310-win_arm64.whl", hash = "sha256:ab64ace1a68682d191d9bedd9d4c939406ad86b1d9f628180410644249ad46c2", size = 54610, upload-time = "2026-09-21T17:54:41.605Z" }, + { url = "https://files.pythonhosted.org/packages/e3/24/3823efc330630a1f132bcd0a9b182ddec3c53f452f551c8e8d3aaf5a2d20/multidict-6.9.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:910d4260512660484c0dc1588a316fbb35a40c081c36fc51d1225351af17cfe4", size = 96739, upload-time = "2026-09-21T17:54:42.815Z" }, + { url = "https://files.pythonhosted.org/packages/6a/69/36331fe1d3aeb0c525a9972d6cf82791075a395aa96f026f060009d691ae/multidict-6.9.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33fa55b990f81c2927e01399ace0d18926c69d69baa8cdaa819424132fb97987", size = 58514, upload-time = "2026-09-21T17:54:44.064Z" }, + { url = "https://files.pythonhosted.org/packages/8c/ca/5860651f782078ef13f2761c2593f4ba066d76f626b2f30f23a7f838b775/multidict-6.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:369b5aa01b241cd3fea6890bdbb11a1425d87bf1515831500d518f4223e9d72c", size = 57303, upload-time = "2026-09-21T17:54:45.304Z" }, + { url = "https://files.pythonhosted.org/packages/c7/9f/c56e2fa223cb5d182660e1a09eec38880f96b7cd22769582f74447c60418/multidict-6.9.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:803f8b575a71b1b299d677c28db0653459c79b5308874efec813f17b7457c7f1", size = 320076, upload-time = "2026-09-21T17:54:46.826Z" }, + { url = "https://files.pythonhosted.org/packages/59/97/eda4fe0cad51f96096363ed32d3e0f8df28dab00beff4d92adc9db517726/multidict-6.9.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8a65621b98984a62e59403009591b8a5a7736273aefe1cab64cfb85b365cc07", size = 315489, upload-time = "2026-09-21T17:54:48.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/14/10b1ded0a4085a51c3054125f854da4f47ce4fec724a2d2503231b83f3db/multidict-6.9.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:13849a1d4f54c3809ae721e9e83ab28f5ea602f33660cb84eb6ef261eac706c1", size = 296716, upload-time = "2026-09-21T17:54:49.749Z" }, + { url = "https://files.pythonhosted.org/packages/dc/14/04d155dd18528443cb9009f3a9f35f7417773797d072c95104ff17b8b9b7/multidict-6.9.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dd9a137a4a9becda3094f3831cd026380f75f6855e051eefe4c73ade524f1cc3", size = 330244, upload-time = "2026-09-21T17:54:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/8b/90/284d10b3a9e5b312a8ec5b7a175fc7d560eefdc99886320449891a197992/multidict-6.9.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9ae9614c317c50836689ce2dfde07c05fe0b16378562e2221746c3913ede3c80", size = 331288, upload-time = "2026-09-21T17:54:52.595Z" }, + { url = "https://files.pythonhosted.org/packages/27/50/f420de3683f9b047fc5588064d84043ef57a7152ef451b8373dfc7068d22/multidict-6.9.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8f1e362c9352b50ed120f001046fdbb80810c9d56580f4c3fc13bbe30823387", size = 320663, upload-time = "2026-09-21T17:54:53.91Z" }, + { url = "https://files.pythonhosted.org/packages/01/ab/0120a650d7ce4fe167299c13a13cc4ee2dc72458a9c0e9e9330b824b73c9/multidict-6.9.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dd655518f136febd96c05131a76a863e32fc2a1d7acd4e3c959e3ceb77d8345", size = 291139, upload-time = "2026-09-21T17:54:55.313Z" }, + { url = "https://files.pythonhosted.org/packages/b3/3f/c66342f73ee5cd2c9b1dda6cfce89b5f348c48921b8c8b6e59aafbbfe31a/multidict-6.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c5d675da8f1cb5650271c8ad5e95c0a3e5a183c105e72d953b12877b1c8d0fd", size = 310044, upload-time = "2026-09-21T17:54:56.745Z" }, + { url = "https://files.pythonhosted.org/packages/05/0d/e44b90d9e77e44ec3473c4c7ac7ea95764d7e8fbf6d5a085ef9bff7ce0c2/multidict-6.9.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:658f90f49cf5af2441cad0a2b801c3ef520471989a1ec55bcb25b255b2ca8d2f", size = 303279, upload-time = "2026-09-21T17:54:58.178Z" }, + { url = "https://files.pythonhosted.org/packages/97/8a/7741f7c23c211fae31ab546f0ba41569dae7671cf7ca30d5afb59e9d92aa/multidict-6.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:43124fe172ada86d03ac3c8dc8179091341f6724d5e5d5b160e1587e4cd3761b", size = 326177, upload-time = "2026-09-21T17:54:59.825Z" }, + { url = "https://files.pythonhosted.org/packages/e8/63/0cdbfbbe36316b2ef4b8e011afaca2cf291a73fe4fabf34dd0b22e3ee48a/multidict-6.9.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2f0adc22a4eb31e545221d93fc73a0f6a8cc2379d0f4309f71d1d17ba938b82", size = 323526, upload-time = "2026-09-21T17:55:01.43Z" }, + { url = "https://files.pythonhosted.org/packages/98/05/0c86e9678e78f4596df0ed7f59d2aa755107da3f2a92f1b7fa150e94cbb8/multidict-6.9.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7fc59e9ba821b220944ccfe0f89c9dc4745f6d097569992356eb03869f21e953", size = 290278, upload-time = "2026-09-21T17:55:02.993Z" }, + { url = "https://files.pythonhosted.org/packages/95/8e/71bd7f43c4883cba6c5f96db88bd2311d52e74ad7680be5915938490d56f/multidict-6.9.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:87cc632c88ee5dc80e12681047839304d98ee5c9a708d686505767001c9b8b9a", size = 321142, upload-time = "2026-09-21T17:55:04.48Z" }, + { url = "https://files.pythonhosted.org/packages/2d/00/bf59d6bb22a4c152e4039574c29490caf3c65107715121807b4bd7cde244/multidict-6.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b828cf64d62dc09ac183f03c1aeedd164ade96a2ce4934109452edf29de1dd13", size = 319045, upload-time = "2026-09-21T17:55:06.119Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3c/f27f4f045198bf1c4bcd6eb3b6cf06be2dceaceb6e67222b903fce7a6749/multidict-6.9.1-cp311-cp311-win32.whl", hash = "sha256:2c1aeb92eea59d824f004341b26d5e4b47a8a441cf9726769b9a90abf9d0e08f", size = 51309, upload-time = "2026-09-21T17:55:07.531Z" }, + { url = "https://files.pythonhosted.org/packages/91/17/c2064aef64efc007bc89a571f843e5772f27946eb9bc018731bf8016e319/multidict-6.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:5f89dad732280e7a10b74d40b91364f88e13c3f2c08c2ef83a8cd42f7a61af2e", size = 59148, upload-time = "2026-09-21T17:55:08.936Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/3bab1827edd813dfb90712af5cbb7fc1473ed4d9c871d103df4f4bb950c2/multidict-6.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:5800368526647146978389dfaa46da3356291e9f0fff9a4ef12e8c2bef964a0d", size = 54258, upload-time = "2026-09-21T17:55:10.191Z" }, + { url = "https://files.pythonhosted.org/packages/d9/0d/4b5afb6d3e545c9af0cdfe2db8f6f4c6664568c23d863d888674e447e6a4/multidict-6.9.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:29138fef49828542e859828107e42e50d0e587c513b7eb4b2d92bade2b0860fe", size = 98360, upload-time = "2026-09-21T17:55:11.508Z" }, + { url = "https://files.pythonhosted.org/packages/89/ab/1b9ca66251899981b21138b87da9d5a9c2c81af12b1ea7d19466972f7fe2/multidict-6.9.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:19e31815d41cefc365489e591d105d2baceb2f65aa75d29471fbdbda8651e006", size = 59979, upload-time = "2026-09-21T17:55:12.866Z" }, + { url = "https://files.pythonhosted.org/packages/36/eb/6ae44062466c26c8469ef43f2481a6a48d8cea0587b2d54514ec92e2adfd/multidict-6.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6ed30be8918e18c8bed0a2e8b70639ecf02feb61ed00ca2e41cfcb2a50fa3f42", size = 57736, upload-time = "2026-09-21T17:55:14.223Z" }, + { url = "https://files.pythonhosted.org/packages/b9/5c/a67817593019257a4ac8b0d1b4c426030e637c047b0692ef405439ecea7a/multidict-6.9.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:637f4ae36264bd7b8d9a60193acddc1d735ad52e8ed53a19931ea6d921fea8e5", size = 333097, upload-time = "2026-09-21T17:55:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/90/bf/599ae2e6222822d88a247a8a7ae82fe6fd25d5700757b79603d5edafe6a0/multidict-6.9.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:35fc236507fb1b3138f0af5ecd5f94ed752d4d6d826248eae425f86204013eea", size = 334196, upload-time = "2026-09-21T17:55:17.015Z" }, + { url = "https://files.pythonhosted.org/packages/15/10/d8aac5acacbe7f5c117866c776ec26d5f15a868759b6f37ad8e7ed3b5b02/multidict-6.9.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e58952f04772f59f11c6e007471449809a30165188669bca8fdb19dde40a8f24", size = 317465, upload-time = "2026-09-21T17:55:18.412Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/714f796f7293a8b1c5c3f465a26996231d5450c54e85d64ef1258c091134/multidict-6.9.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d35a4f1c63f07fbb8c8f9946dea98b21eddf6c57421585f71d91864be3ba2a24", size = 345914, upload-time = "2026-09-21T17:55:20.306Z" }, + { url = "https://files.pythonhosted.org/packages/da/b1/e37fbf769c567be277bcf32df6234035a4384677fcc1bd852752be3d6b93/multidict-6.9.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b5f8771aaaed7f80e84a4e471d2f29ab6721e4595075e54d03ae1ed951b2000a", size = 351163, upload-time = "2026-09-21T17:55:22.21Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5b/db08419c1e1f7c9d60cfd2787b2b517d7ae4ebbda8281b48a33eb5141467/multidict-6.9.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:976fd7689d69ec78d67d31d38d396d8adb562f7e8368279f76aed4aa451fa06d", size = 336881, upload-time = "2026-09-21T17:55:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/9b/07/cc9bc8a62651d2d53ab93ee4993b3a71b7cb78eb8ebfc9c757a5b6698617/multidict-6.9.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:95052e8777a86bae87c0bd0b5ab22d809e3d1d02bf69e3e66ddda5ba75a05805", size = 303405, upload-time = "2026-09-21T17:55:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/b6/0c/8e912afafa70e944dbb8bec4b66ca6e008511395278c0d3dd0e89567536a/multidict-6.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1a8adfcaf96f587ab138476eaddef95f29b8a2a8a9afbfea8d2fd62180995d02", size = 324265, upload-time = "2026-09-21T17:55:26.989Z" }, + { url = "https://files.pythonhosted.org/packages/66/6a/62c2af80fb085e6234805017af857b8e913dfac7a55e9c1349c27768c58a/multidict-6.9.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:1a53de2772cfb74559df2eb4456ec4eeb908435ec55a84b69370d9d745d62aa8", size = 322085, upload-time = "2026-09-21T17:55:28.732Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6b/35bf801b336fd960811207203ffdcdc24acc3558b3e7ff2e1c914b141e70/multidict-6.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f2ce963299d42fa3f22a90adc0fdf174792ffef5ff4c7ffb68260548fb05580", size = 338107, upload-time = "2026-09-21T17:55:30.303Z" }, + { url = "https://files.pythonhosted.org/packages/80/41/495ef65bf5bba29d142d81b3fe1b8154b919bed70e96491b1e93c3c26f0f/multidict-6.9.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5b30ddf7234e611ca877575b62840e6af5977f92f1f9d532eedbb05a44ff8004", size = 339631, upload-time = "2026-09-21T17:55:32.012Z" }, + { url = "https://files.pythonhosted.org/packages/19/bd/057fdff5f4e04dcd40a960e38f77d19d3c4b67dd243ffa5f43718edb29fc/multidict-6.9.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:63ada7ee2e9345695f9e9bc4c65d72222253f07b1ac94fd0e37555cc6f3c7f60", size = 302080, upload-time = "2026-09-21T17:55:33.817Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/9ace933ee8dad808632523726f42255b09600087219e3d4ead7369820910/multidict-6.9.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3c95601ed98fad3f6e2f8fe809c3b526b0fab31ef525e00a155e227f3d17f58a", size = 341107, upload-time = "2026-09-21T17:55:35.471Z" }, + { url = "https://files.pythonhosted.org/packages/d8/ac/7c1204406097bfc5c283d4a3287d61166807d189917567df4c1318484fc4/multidict-6.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c148e8b596000dd3e4bfe206e70f3e666be18d72032e0012555f2373c52e35d6", size = 333482, upload-time = "2026-09-21T17:55:37.002Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c0/a70c32ea3299ebe00f44533740cb46905717c51faf29bbd3ce8bb5886d9d/multidict-6.9.1-cp312-cp312-win32.whl", hash = "sha256:f9dad513626a33670f17cddc6078e30e311f444c957e8dbfc5b2b4603c8b4edb", size = 52548, upload-time = "2026-09-21T17:55:38.529Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2e/8c9c2591df01e5692ad1bf92febb082a17b1c6c3a19aa8b7ff49988fd989/multidict-6.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:a16a1dc8529f9e734a41c3b856f3eae7ebacdc061dde3f8a844e0c7889c97203", size = 59622, upload-time = "2026-09-21T17:55:39.86Z" }, + { url = "https://files.pythonhosted.org/packages/d0/95/59f4472ec512bc180fd207899594c7803ece96262ff9aaa3a4b6d7da6940/multidict-6.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:361f7206cf341ba94fb015688f5c8b480f8e63bd58a4c14a48aeca7851a241cc", size = 54930, upload-time = "2026-09-21T17:55:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/eb/45/ddf7c76860f5f553a23ad3ca38463ccf5247081618977742c8dc4625355e/multidict-6.9.1-cp313-cp313-android_24_x86_64.whl", hash = "sha256:d7bf9e43282d69561618e8a0ea33368d532ebef42f15c096f427090521dd74f3", size = 63266, upload-time = "2026-09-21T17:55:42.676Z" }, + { url = "https://files.pythonhosted.org/packages/6d/d3/f4ae5945ea2de597eaeb4eca3a74c59783ace54482c9b49435442b63efa8/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:03d47df72f084f757c1cb771188d5f4e3a805e4abc4d67e32509272343ae9382", size = 55771, upload-time = "2026-09-21T17:55:44.016Z" }, + { url = "https://files.pythonhosted.org/packages/93/7d/15468239920040d01c686e5ae669e6382f7bf31fc3e5e0a6b8f1c3b32d3b/multidict-6.9.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6bc94fe17c3c56e5418f79515b786b101845f70609b0d19d0c1ba13448e5633a", size = 57116, upload-time = "2026-09-21T17:55:45.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/1b/b958f06aac2d8b1e485eb1c105b1b159cbf3868249e5142142451577dad2/multidict-6.9.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8f2973bbd2bebd9d2e0cd6394c1292a1a19ccd56bdcbe1e174059f1a39be5b40", size = 97692, upload-time = "2026-09-21T17:55:46.674Z" }, + { url = "https://files.pythonhosted.org/packages/93/6c/d6cfe18e61010166d7237d7527c775eb9843e7078feadea45b7628751b60/multidict-6.9.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:de7738b8c0bb74c4cc16bbd7fb49fc2bcf6430dba11b3432cd52768ae40933e8", size = 59556, upload-time = "2026-09-21T17:55:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/46/46/9b4c1127cece0289fb207d04aae5116382ddfcb2a4dc8e0cb49a33f3c7b3/multidict-6.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e5ccad4b7bac125722f48d6f862bed3b514d8526deea06316bb72f152cd30a7c", size = 57357, upload-time = "2026-09-21T17:55:49.998Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fc/c18b07100a6064573e49e538d13d47eb2b5221f48080512df78aef54ab04/multidict-6.9.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a49ff5cdb33654cb7d6a3c377aa2a83ddefaa1db31eb10bcf3c180aa84f9af8a", size = 330433, upload-time = "2026-09-21T17:55:51.435Z" }, + { url = "https://files.pythonhosted.org/packages/62/ff/52a0082adeb69656b634609d4fb0ae65456deaae4cca3d5a12656626dc50/multidict-6.9.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b22ff30006a2f28f8bff878fb93413cbe3a4d1fd517c28081d848c90e9cfd2c8", size = 332487, upload-time = "2026-09-21T17:55:52.937Z" }, + { url = "https://files.pythonhosted.org/packages/39/3e/80bd4729635a7af371ff4dcc312594cf96e73f98854491be8740792de430/multidict-6.9.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3adf06c66041aa21eeb8a71e82379b74773298c8e6d3d839b151aae441a99b94", size = 316911, upload-time = "2026-09-21T17:55:54.751Z" }, + { url = "https://files.pythonhosted.org/packages/15/f4/7ea4a907e053e924e2e1694d90560f79f72056504bfd5e8483695f9527d2/multidict-6.9.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:963a8d8f97057082679523d0fd4c53a38f86bc58cabe4556faef682ae53fa2fa", size = 344950, upload-time = "2026-09-21T17:55:56.5Z" }, + { url = "https://files.pythonhosted.org/packages/0d/16/9642ae41fbfbdc546aa481c2b07ae1bc087a94ab5f5020b4d9ab77da401f/multidict-6.9.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b66ccc5c2cdd26e74fa5d4c29ffae424cc6148bf93ce574821783fb3b6d452c5", size = 350264, upload-time = "2026-09-21T17:55:58.073Z" }, + { url = "https://files.pythonhosted.org/packages/93/f2/e06c8e42074d0a4b8419bfe92afbd1d262190b69549ecbcd2dffa4f103c2/multidict-6.9.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7fd79c521f6290c69125fa2b85fa65d9e657e6a8ffaf722dc881b926bef4aa5c", size = 336213, upload-time = "2026-09-21T17:55:59.723Z" }, + { url = "https://files.pythonhosted.org/packages/98/43/d7cf9ef4700e7c25244590d1b4f20cf12cbed86beba6c17be4d9e48c1099/multidict-6.9.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1d804e4caf5d5da37d6dac1325da5629ebef1e27a294c2b568b295814aa36c7b", size = 302555, upload-time = "2026-09-21T17:56:01.666Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3a/54209920324bc3928f5f2ba28b01a6dd957a9d48c3aaff77e38faaf5668d/multidict-6.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bb0d664505f4b112f384cffeee82e91e3f6448d8e574989479db4439b68cba05", size = 322247, upload-time = "2026-09-21T17:56:03.265Z" }, + { url = "https://files.pythonhosted.org/packages/33/50/df96f961b178b621ab0adbf9221d4fd21534e1064fd9f5e85c1fa27fea55/multidict-6.9.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:9e14d17773b1b3c758ff153659a1824608a0cb562c45f484b5ed8a433428444a", size = 321787, upload-time = "2026-09-21T17:56:04.894Z" }, + { url = "https://files.pythonhosted.org/packages/a7/9b/37f354562a8f82f9c1f63d3a94300fc82126d50320175f0702bbd544f87c/multidict-6.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:083735b7f395894e43adb278d5dae901448883a835ff8f1977e285fefdb10418", size = 335498, upload-time = "2026-09-21T17:56:06.783Z" }, + { url = "https://files.pythonhosted.org/packages/1e/44/78e366efc6c004185295cc9eb9711c10f13eadf3b547603b9c5526329976/multidict-6.9.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:b65121091567847a8cb520d364ab22ba90e00d3cc55fa9eb34bb439f0684bcd1", size = 338244, upload-time = "2026-09-21T17:56:09.162Z" }, + { url = "https://files.pythonhosted.org/packages/ad/2b/ab5bd3964691abe4d14b43bdbdb8a621b77cea2ca352dc93159ec0a4575b/multidict-6.9.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:ea999ae6e80e66ad5eea287860951b033d0104ca34d6d87c7b5125ebe0e12721", size = 301641, upload-time = "2026-09-21T17:56:11.227Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a8/f6bc899f5aafaba755edc8e4bb934bc00b1e405f8d3fbc1dd10283738074/multidict-6.9.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:095d900c242e00fbe5f321ee072e7278b4153e78c5ce9c1efde167d62c1e4771", size = 340162, upload-time = "2026-09-21T17:56:13.076Z" }, + { url = "https://files.pythonhosted.org/packages/eb/03/a8fc809ef364b8c231c065ea2ea2f97ae86d4f9a0e36b5721759fd606778/multidict-6.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6441cc837aea58be7d9baef1b2383eb8311ab9303f500f99ac90b584cd78bb14", size = 332826, upload-time = "2026-09-21T17:56:15.175Z" }, + { url = "https://files.pythonhosted.org/packages/f2/27/32dde80245e2024e9bb7a6ca3c2fb7c695dc74014cba557cc35be6f91e24/multidict-6.9.1-cp313-cp313-win32.whl", hash = "sha256:9c4880d017555d70dea367dd49271830842d48e3891c2da97da7ce8c4abcee40", size = 52378, upload-time = "2026-09-21T17:56:16.959Z" }, + { url = "https://files.pythonhosted.org/packages/be/78/1bac2987edac273a6b27fa50e9204bc3466c94a7b4f6a44828d730f8810f/multidict-6.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:ac51cd64bae51c462ea58ad2492c9b8209667a4ef60c45c4a304518b67598d5d", size = 59449, upload-time = "2026-09-21T17:56:18.336Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/2a77ce19eea48cb9b3521a202b513ba3349aafa702c8317be0b645ffb74b/multidict-6.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:37a9ebe00c698279213d56e6c64e1962ab1e092918270649b397cac3dc196ca4", size = 54713, upload-time = "2026-09-21T17:56:19.774Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0f/c6041015aa2cdc11e1dff0cae58898ae5525d19e35efd65a4ecf3c6ea235/multidict-6.9.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:fc0dcb22fa9aeabfe3fa4e0430099acff985ec5d77a851382f76cc6146e780e5", size = 62336, upload-time = "2026-09-21T17:56:21.181Z" }, + { url = "https://files.pythonhosted.org/packages/23/db/95bd0afb130a0149c955f1f066143528d60864e64f41ff5a1e1313609361/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:024123f0ab402ab33828e24eb80fa8f25167d0d3783ba5f287e39ed741e6abf9", size = 54892, upload-time = "2026-09-21T17:56:22.63Z" }, + { url = "https://files.pythonhosted.org/packages/94/cc/8aa34ae8d09498e8da003f485c03371b55cf1bc6d56cff861b3a7de61251/multidict-6.9.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:af1b5a92315048c3e36bbebfa7d4760a9c3e910bc4166f11b20d77d20d6bcfca", size = 56251, upload-time = "2026-09-21T17:56:24.36Z" }, + { url = "https://files.pythonhosted.org/packages/52/48/22baa3b95375096369b04c4348af14139570f302f951773d2c68bf0ccf2e/multidict-6.9.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b2483da477932ad1983d1d33c18bc3771c6fb00cfbaaed70a875fd547ef8e840", size = 96345, upload-time = "2026-09-21T17:56:25.777Z" }, + { url = "https://files.pythonhosted.org/packages/2f/3e/b96779dcac28ec6d491fd821112a0156b519b6701bba186cdf6f6df737f4/multidict-6.9.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db4d697b18b6ef5528b1f36bfa25072cd2a421869f5963bc0e92c8a34b9e2800", size = 58901, upload-time = "2026-09-21T17:56:27.364Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b4/8e6d950f02ca6ecc00ecf671f2ddb5dab7017671a8d197326f75621f5786/multidict-6.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:854fd2f1bc6e8a56b89910b5cd7261a8b40f13ebb31572da985ce59c7da0886d", size = 56491, upload-time = "2026-09-21T17:56:28.901Z" }, + { url = "https://files.pythonhosted.org/packages/ed/8f/212ed7e03282c7bc271e3796bf985b53d5727c2827361e3dd9c5ee6b093f/multidict-6.9.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:03731f6fc036180700c9dc2308205a48e5ca6f3ff03087739ab746f294022201", size = 332297, upload-time = "2026-09-21T17:56:30.495Z" }, + { url = "https://files.pythonhosted.org/packages/a0/97/555aab000e03ecba85c0a1837fd5674d8bf10949490df7800da3a40660ce/multidict-6.9.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:989261c5f1735a165f2e4e87cf6d5f17ab734fa18f9ad0383d5adfcdaefce701", size = 329197, upload-time = "2026-09-21T17:56:32.652Z" }, + { url = "https://files.pythonhosted.org/packages/3d/25/f3539b5bdc147beba66015795dfe589eedcc580348fb1a8c69b1df4196b5/multidict-6.9.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b4c9e5d05b126b267ac048a89a0e2d9b48b1b648add62d4872906304a8590610", size = 308456, upload-time = "2026-09-21T17:56:34.657Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7d/4665cad8fc326787d218879be879d86dfe32f006c1f88ae47b04d3b52fd5/multidict-6.9.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9d0a21cf76153de8f2d96a877991d6bc59b9ab5180b949e50db73cc193b4a694", size = 343382, upload-time = "2026-09-21T17:56:36.606Z" }, + { url = "https://files.pythonhosted.org/packages/41/01/e1e9abe27f492b22dede492bc423015597412b14ce074f7475f4ecd80185/multidict-6.9.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7386aa18d98d6b8af44b92173654ec469237fda35f8e8523e43b581a86f476a", size = 346848, upload-time = "2026-09-21T17:56:38.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/8d54118bc228e64e1087f1729647b93bbea225eda4a3f914663a6f410bca/multidict-6.9.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b69651732c64afb691e50cdc3387cae305e0eeff8804fe3e3ce203876494932a", size = 333612, upload-time = "2026-09-21T17:56:40.041Z" }, + { url = "https://files.pythonhosted.org/packages/76/e1/9509dafc1fc68948e75842a74533ddfa64af57f2b17810e9cacfa68a829b/multidict-6.9.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1fee9a16d88a1c4865610de31ef5c666671020d7a81d1f510eaf3c97d00ebeba", size = 304246, upload-time = "2026-09-21T17:56:42.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/b6/96b93808e1bec0b51d49d89859e49b82a94f7b2e58eca0fc44ffcafd3ae5/multidict-6.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7814bbee202acd3bd240204c17d8b87a4c48c81064fd8674dbe527d94d5a4290", size = 321983, upload-time = "2026-09-21T17:56:44.399Z" }, + { url = "https://files.pythonhosted.org/packages/d9/1c/08e5184df026b9d8e05a6dce5da22628e46bc4a7c7f03b8bf3c6939a8c46/multidict-6.9.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:95f273bea318a194f656527ee2ed19494327bc500b8e87d9358e3222579aa28d", size = 315927, upload-time = "2026-09-21T17:56:46.208Z" }, + { url = "https://files.pythonhosted.org/packages/d7/5d/9ff39e7387f6fcc9a81af5dbf055fde08149849a04f8df10ca5b086b0d39/multidict-6.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:73bcafa21a78d0776b3ee7cd2a63c66f968eb7db8e8d594e32d7329950f6e828", size = 336827, upload-time = "2026-09-21T17:56:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/5a/be/30fbb2cab22203128928066de94be247dc9a3230bf2bfc54437ac3b5f9f7/multidict-6.9.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1a938761c77e0e6edb0c93d02f4e988d44a69e5195e5a3b893e5553311347132", size = 336317, upload-time = "2026-09-21T17:56:49.778Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/2480aef6b7d8e7ab89b064ae14f12ec069570dab3a5c13e0155912293247/multidict-6.9.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:37245ca4105386194dd1d292a6f2aae09bfe1bd7ac6f9ec25093e3cf8e9b143b", size = 303258, upload-time = "2026-09-21T17:56:51.829Z" }, + { url = "https://files.pythonhosted.org/packages/4c/04/dbe59cf4ea3778600e8e70e30b31aa9118ad6da2844bc9f717804cbaa000/multidict-6.9.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f0700527dd5bfa8b7204b08330542f4f388899d3c14d885d8a368992e0eb562d", size = 337232, upload-time = "2026-09-21T17:56:53.537Z" }, + { url = "https://files.pythonhosted.org/packages/f4/8c/abe55548f06f12878588f08eb803c5a391283f01243f784cb00adac79231/multidict-6.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6b7e54fd883671d1a8810704851c044b7173287c552b9f5c3d9e0eb9f00ae194", size = 330261, upload-time = "2026-09-21T17:56:55.335Z" }, + { url = "https://files.pythonhosted.org/packages/d0/f1/ba67f668478f152bdfa18abe72e7588bd078bf8b42c172fcf542fcf45826/multidict-6.9.1-cp314-cp314-win32.whl", hash = "sha256:e81ae656b9935ac4528a71f96bb7a14d949778ed1897c573d3e7ebb9187f8841", size = 51715, upload-time = "2026-09-21T17:56:57.048Z" }, + { url = "https://files.pythonhosted.org/packages/33/81/014bb2128aedc8157d2848f0ec8a0209c6b822fb88deea012834e1c4a4e8/multidict-6.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:acddcac38adc8342ba48aba98896faa7928854bebb62542362138655b5367ee3", size = 58621, upload-time = "2026-09-21T17:56:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/66/34/8f43c03c2d8821a2320b8c560d8a49d0be2920627edfac3f546922f91969/multidict-6.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:32217133dddc58c927805cf6c0731d8144584176b768042ee886d51e71860bc9", size = 53959, upload-time = "2026-09-21T17:57:00.15Z" }, + { url = "https://files.pythonhosted.org/packages/08/64/4f2a5eeea10b6d42634ba168ec6efc732ef218772e57b8239cd485c202f6/multidict-6.9.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0b2fb8c349d1103863750b5d8cb5ace766917f4b35f3d883c8f778853eaa9f76", size = 107694, upload-time = "2026-09-21T17:57:01.785Z" }, + { url = "https://files.pythonhosted.org/packages/32/ce/c68d08ac2096c1528ff20d77fd10f7e401db3e534ac5a1c7726e36a77c2f/multidict-6.9.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:56d834b74c993a7d7cb2b8ab33a0d55c3e0d4a3d2f2da2808a4ad3d79189711b", size = 64766, upload-time = "2026-09-21T17:57:03.52Z" }, + { url = "https://files.pythonhosted.org/packages/3a/9a/9f5c270c88fe289b4876fe28f9932a70e015a7916aff1e5b0325f7597b11/multidict-6.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:783ba7d845d79ce976afd9c1e91a4e5714671defa198ee789e8b23316083a485", size = 62723, upload-time = "2026-09-21T17:57:05.104Z" }, + { url = "https://files.pythonhosted.org/packages/7c/63/c9a0b131354dd949426166cef712d708f436a8cf261114f8d5d2aec440ca/multidict-6.9.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d718fa1b5f0d0dd75e86fbbc5b0c93ea3a5d65216c85c61cd5d7cdddfe08455", size = 290445, upload-time = "2026-09-21T17:57:06.687Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/ed27d1d20ba91f4e13e8f7dcfaa592614ae0e1d5f45e03171d23c3b098ac/multidict-6.9.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a313bad717dde740959d50850c315b75fd4eb0c5e6b4dc8535db0f1c369be125", size = 304375, upload-time = "2026-09-21T17:57:08.717Z" }, + { url = "https://files.pythonhosted.org/packages/66/6f/a7b26167173c3b73d6e5ebb0856c5154ad005156c3a2fa4062329921819c/multidict-6.9.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ef01d29fca550ab871fd99154f82c6472aacf8e7def272dfb07b460123850390", size = 281746, upload-time = "2026-09-21T17:57:10.713Z" }, + { url = "https://files.pythonhosted.org/packages/34/1d/da27bdd49b0cb84263288f5f48dbd915f2d17fd228fee2078ca607e80277/multidict-6.9.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f3bd290711c6e9486173a6ee7cd4e7f00c3971c7908c1ec1b6e5437c5e4c6f9", size = 311499, upload-time = "2026-09-21T17:57:12.467Z" }, + { url = "https://files.pythonhosted.org/packages/d0/36/ab0299a7abf53e38ff75d96c64480f1e2ff8edde985f93f7a1ad5e08bd94/multidict-6.9.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:81cdc537e0a42e3c0170752fcadbe450246d5c3b4b7231d6eb9672456605ac94", size = 313553, upload-time = "2026-09-21T17:57:14.548Z" }, + { url = "https://files.pythonhosted.org/packages/5a/44/e21be702efc9c9ba097c8be874c68860b1cb6cf9c85611263039bf95b37e/multidict-6.9.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b193bf7a443c97d81c47052f60c486071a4bdef4a573fa2514f920089414d45", size = 299898, upload-time = "2026-09-21T17:57:16.192Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e93bc0dc947c7f872e502cfaa7ad9d98f001e1c93cfa6f4940457a6e187a/multidict-6.9.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7a0c98f6a636adf0d7edd60c61589eaf52d149239af754d0bfeb0effedba53a8", size = 273054, upload-time = "2026-09-21T17:57:17.793Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c9/945b44493fbe7af26ffe1e43c506b376daeab070fc1a0923bef9bbf572eb/multidict-6.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6e6b7e3a1520c39a2772f414cd9ddf5995f77e4e823dfa1522af383addd465a3", size = 294751, upload-time = "2026-09-21T17:57:19.631Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/0969a9b38b814e5fa3af1b0a57708c49b3d9b3d55c208bac6e98b87bb028/multidict-6.9.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ca65cced0d67a9039e93bcd98a369920e499bf98296844ff56ead01c9085321f", size = 284523, upload-time = "2026-09-21T17:57:21.313Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1e/c32ddf53234c228b1a374ddabca19f542ac7f32752f5155982abe2304833/multidict-6.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:372c063f37480f62c1ae32dc3a1a0a5942b180c883f99789075a8a8ec3c4e709", size = 290275, upload-time = "2026-09-21T17:57:23.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/7a99a3daafd987330abb865a2d36e432fd8bf18e790ddda55c5de15eb98f/multidict-6.9.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:827c92145b3b976b39430129c89d213b250dacbe5fce678e9a03940e6e848983", size = 303350, upload-time = "2026-09-21T17:57:25.086Z" }, + { url = "https://files.pythonhosted.org/packages/2e/09/7e6ebaecc4e226d7fe27973c271d58b75c14c24e84c23736d21dc39997dd/multidict-6.9.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9b0124b9c17e9890f0819b2e7a5f65ec9a2f5aabd6c8e7ad090c1675cf70dee6", size = 272232, upload-time = "2026-09-21T17:57:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/1f/6e/420e9e879b21cbdb5036214ee238efabcfab7bf262d3fc124132050f79ff/multidict-6.9.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:23136f5a564654eb61061ec6d5620a4c1ea32c8f552b65e9982a12b72bff601b", size = 300525, upload-time = "2026-09-21T17:57:29.004Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/c6b0896850b3ceb190ca302760d63148843371f027c5dde3d1d732343a08/multidict-6.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f2524ec55b3e65cbe235a8b3c36e2af3b635be02ed05e20c94e70c5c943c009e", size = 295933, upload-time = "2026-09-21T17:57:30.837Z" }, + { url = "https://files.pythonhosted.org/packages/29/f2/f0ff3384a756227741c72f4ee94cadf1f9900df0ae961e48639f96115b1c/multidict-6.9.1-cp314-cp314t-win32.whl", hash = "sha256:35ba0263bae5dd3ad5aad767cc9afc01a8598c7dae30f1b3b2de98b1b32c28bd", size = 57776, upload-time = "2026-09-21T17:57:32.835Z" }, + { url = "https://files.pythonhosted.org/packages/8d/87/013e1eed30ad46bec1eb68947bc9caf55ba2fd0d6aed04fe964e3b44c23b/multidict-6.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7c8d5882ba25ac0282258be435d8a05aa0cbcacfce15799154a338f847f159b9", size = 64881, upload-time = "2026-09-21T17:57:34.489Z" }, + { url = "https://files.pythonhosted.org/packages/7f/95/64dd049bb4c53426e2fee7afb874daaa8b4426c41878785883a94511ecf8/multidict-6.9.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ac348379cf4de5538a0a213be1532d289aa801ec5d267c5909446b9ea2f8e2c3", size = 58582, upload-time = "2026-09-21T17:57:36.19Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2c/26bc1723592cfe965b575652a7749026474b0625b04b04564e1fba265dc5/multidict-6.9.1-cp315-cp315-android_24_x86_64.whl", hash = "sha256:f04551dce5a7db8c9659f2e4245494c182d0663b83661803e08d46bfcae5eda1", size = 62339, upload-time = "2026-09-21T17:57:37.967Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c5/614d2eb9995232d066aae7f17d1d454060b6b5d882b22fbaf6150b05ef1d/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:c0c88085affd35c33e124e36930c5ad96aff9294ce195eaa0fd9cec962b64a82", size = 55009, upload-time = "2026-09-21T17:57:39.746Z" }, + { url = "https://files.pythonhosted.org/packages/7f/24/f7179ecdf9cdd3581ace3292c3b883a1a500a374aa4e619bf6eb521c62e5/multidict-6.9.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a8b75dd3d3638d9a19f23e84af4ffab3b8940422c0df2da2a77005ef5aa3d7ea", size = 56273, upload-time = "2026-09-21T17:57:41.544Z" }, + { url = "https://files.pythonhosted.org/packages/ac/72/c9dffcecccd1f8b9a58f960ec4eaecab62c4edb08d5bb984d4de67fe3fdb/multidict-6.9.1-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:006c4478de0a1876f4834e14255776286f09b9846b505fe63f67f9d173a9487c", size = 96390, upload-time = "2026-09-21T17:57:43.385Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3f/76542360e655cecdb7c158c7af88c69c4db0d2aac3421659d41214927229/multidict-6.9.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2784090c30a586d5b45197bd9c32f87fb927216cde302f6cfd76d76577e90f08", size = 58900, upload-time = "2026-09-21T17:57:45.105Z" }, + { url = "https://files.pythonhosted.org/packages/04/15/f28c71af461dd4ff15653a165cfd8ddaf01f3387ff80d2dd0de057d527d0/multidict-6.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:7c708566da8014b120a64b1eb6d200c6c0c8cb36296383723cdb6fc82038270b", size = 56540, upload-time = "2026-09-21T17:57:46.913Z" }, + { url = "https://files.pythonhosted.org/packages/19/de/5ee4050c3ddad88c605a89fc1cd18e89abd40a64f183e6fb5276b6eb7f52/multidict-6.9.1-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:042fb0196047e786936a730bd302de83143950da45f2c16078da8f35e1cf7919", size = 331818, upload-time = "2026-09-21T17:57:48.825Z" }, + { url = "https://files.pythonhosted.org/packages/70/e2/2d4e3c3ee70f583334ff236fe6a3babf7a05ce56169a15df65453af647c0/multidict-6.9.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2212a0c842c723d919ea4a22a9296cb6b244b386e4e6ea92adfc7fbf3095519", size = 329800, upload-time = "2026-09-21T17:57:50.91Z" }, + { url = "https://files.pythonhosted.org/packages/d6/14/f64bd05667343a7250cdd99f81bda2270e3959f00832441f10c7f904e1c1/multidict-6.9.1-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd882aa29bf402b62bf1fd7c19fd5df4b6528cf468a908864b39368572b662a9", size = 312048, upload-time = "2026-09-21T17:57:53.053Z" }, + { url = "https://files.pythonhosted.org/packages/64/bf/a91256ce00199e7544e3eda79c4e9544eb7388c38fa22f11638c2e357ccb/multidict-6.9.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c57541034d12b215ab0a2bfa371d1a8a198da18176d0426c27105b9161a6862d", size = 343676, upload-time = "2026-09-21T17:57:55.069Z" }, + { url = "https://files.pythonhosted.org/packages/14/48/628b978413518159228c2b2ec38645362ceb6427e935d83d716e26490a49/multidict-6.9.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d73fed4e37158ff00cd271871170b79e40138db51e625fd352fa17c6acb34f67", size = 347028, upload-time = "2026-09-21T17:57:57.157Z" }, + { url = "https://files.pythonhosted.org/packages/8c/73/5f9dc2b5ac7dd7a82458ae9cd283b99c185834f23442ace295362dcc5e45/multidict-6.9.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:79da2491348b30810728050b4a8ec0416f85884125c2fd44655b3d01150d9c3e", size = 335670, upload-time = "2026-09-21T17:57:59.126Z" }, + { url = "https://files.pythonhosted.org/packages/f8/84/6f319ee000f5ab3fa0055b194ad706fddd552c60c76a21d581c87db1d46c/multidict-6.9.1-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b4da208a63434d21a3df64d29758e650fc4aa8cb05848554b76949c296539cca", size = 307804, upload-time = "2026-09-21T17:58:01.2Z" }, + { url = "https://files.pythonhosted.org/packages/3f/24/61be5fe1616d033532e8d63dc789c0f35b9793917834713eefe050589ba9/multidict-6.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:877ca17fdcfdf5c397493a71e5ff97a87bb181417fe717fdadc77c08c09301ac", size = 323409, upload-time = "2026-09-21T17:58:03.383Z" }, + { url = "https://files.pythonhosted.org/packages/22/a6/eab8aaf59892169a0f4e8e78a5516a6caa6abd8e96e6410111258f4ef0ef/multidict-6.9.1-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:40aec5299e1ed71fbb988389059da381c3c1a60e0c649acceb2a35d9b128848e", size = 315746, upload-time = "2026-09-21T17:58:05.372Z" }, + { url = "https://files.pythonhosted.org/packages/42/b1/d1e14bab80a74babf6e2ac971a878b84fdcf3a972f3ec3400ca12260fac5/multidict-6.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:4a409ccc42aefec904038695d5d7fd6d8f3af2721b7b6401d55135ec7d6d298e", size = 334529, upload-time = "2026-09-21T17:58:07.35Z" }, + { url = "https://files.pythonhosted.org/packages/f5/50/a05b1d36f6d3363a5a334cebbf11624c9c7a69d66fd23345eb53b1d43b7e/multidict-6.9.1-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0a5769559e3312dd96731fbe15b4abb6033368ac1cad5a98dadd21946a4c7d6c", size = 336455, upload-time = "2026-09-21T17:58:09.112Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0d/bf33cb097380252b08e1177b5ae0cab822c47ce80d236f3f724953c76721/multidict-6.9.1-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:03fac50ddfd8302175b77863a015eccfae76767cda5506eef86df559ba861e1f", size = 305865, upload-time = "2026-09-21T17:58:10.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/ee/a2bd133391204b1cec5a5caf327dc9a06710439b7403220860a8fb164560/multidict-6.9.1-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:5ecace251ccfa705bf3d7d35c5032cf750f5c629809740405697bce5c118c4a4", size = 337531, upload-time = "2026-09-21T17:58:12.846Z" }, + { url = "https://files.pythonhosted.org/packages/a8/32/c0972486f81a06c3bbb23063c85afaac53c51608f7e35bcb101a1006408b/multidict-6.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:ecc89dbd4155b2f8a47f4bbd89242a35ed15e8e0ec581cab5ac65fa38407329d", size = 332464, upload-time = "2026-09-21T17:58:14.673Z" }, + { url = "https://files.pythonhosted.org/packages/69/4f/5cbbe852b45d8a7f0ab3ed042386e4894e463010eb230603596cb15d0fee/multidict-6.9.1-cp315-cp315-win32.whl", hash = "sha256:a3ffe881246d28a862f1985824f484cb7361f44d6b99c4c620436ef56462f38d", size = 51714, upload-time = "2026-09-21T17:58:16.409Z" }, + { url = "https://files.pythonhosted.org/packages/d4/77/1fa118386deeae4e7a48870fa5cb073e748b0ac5265a6990651cefc48068/multidict-6.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:59c123d0e948d760a5f930f316cfefa07e8d632ab84327c0693ee6a88171154f", size = 58619, upload-time = "2026-09-21T17:58:18.369Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ee/51c45748acfce9cb69d89dcfcd09c874b385407fd6993e885d6905659865/multidict-6.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:31199204b3ced121ff5407a2c342326a5d27e3870abbf94bd80dbd2451b7bc8f", size = 53969, upload-time = "2026-09-21T17:58:20.102Z" }, + { url = "https://files.pythonhosted.org/packages/aa/57/89d2d3dedfae2558853c1770c7cbcdfd5211eb4c4090b1e8cb570544a490/multidict-6.9.1-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:8879510a76940670517ea1cb589978da44b86e286ec3e50d664ef330817afce7", size = 107679, upload-time = "2026-09-21T17:58:21.98Z" }, + { url = "https://files.pythonhosted.org/packages/c8/df/0c2a28870181c1762f90952b897f6db46c934ce073efe0442604cbc276db/multidict-6.9.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:aec65b53a07f580606593f877eefbb29a45939bfc0d3fe6e6d9f42b41b749f68", size = 64753, upload-time = "2026-09-21T17:58:23.622Z" }, + { url = "https://files.pythonhosted.org/packages/43/7e/9c6a3e7619459407d8958f7e56ab3dda7f0738f5aed9bb4ad48076e8de83/multidict-6.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:14c56f73e78faa1f68bbb826197cd5871994e70e841b8590829c35912ece5c64", size = 62732, upload-time = "2026-09-21T17:58:25.46Z" }, + { url = "https://files.pythonhosted.org/packages/38/eb/fd56c9d83ba0cdc3cf66f9acb278b24ef1f043b620b96d145685a3b57338/multidict-6.9.1-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:38c9986f9ce50c459b10de216a05f4bd7ed5ac63887d56e500a52bb464b861ce", size = 288648, upload-time = "2026-09-21T17:58:27.115Z" }, + { url = "https://files.pythonhosted.org/packages/df/ef/51e79c2442b0f56ac4ac580108d713078b31297e028d468fc220971a305d/multidict-6.9.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:15db6a102cbaf1949cf028ecf080aac76d20bcd29ad4e092574db6c6b7af78a5", size = 304919, upload-time = "2026-09-21T17:58:29.143Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e4/774700cc5739353fe88a7204ed30e78a9403d98d4bb2496cc91f096d2e71/multidict-6.9.1-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd82c4977196681a499bb6ca9e462afbc5c91c1c15b6a991dfbd72733fea4dd2", size = 289094, upload-time = "2026-09-21T17:58:30.938Z" }, + { url = "https://files.pythonhosted.org/packages/ca/41/0bec327bc3a8271825376b6d8a469d1f80e3ba1e2e730e019836e29663c3/multidict-6.9.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bb58ba73a3f96f9a3e46b1fab69929d7edbbddb3133ee74b5c3c54074a53c4f1", size = 312007, upload-time = "2026-09-21T17:58:32.964Z" }, + { url = "https://files.pythonhosted.org/packages/c3/7a/9906bf5f1fe20e8e755affccc1a69ecd8aebb079ac65386e5ffc8eefbe75/multidict-6.9.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7c6eecfab7ce4cd9487ff8ba936fe38cdfd68c04faf3d5c710363c6a8e695659", size = 314657, upload-time = "2026-09-21T17:58:35.285Z" }, + { url = "https://files.pythonhosted.org/packages/33/92/cf6d665a45663bbe216420fe632da10e9f4f842b07ce02bc3ddf8eb882b8/multidict-6.9.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c62e71e6289d78c0108d8aeb495f8bd3cad4bc1632fedddc9297ddf287ecc20", size = 301563, upload-time = "2026-09-21T17:58:37.472Z" }, + { url = "https://files.pythonhosted.org/packages/97/4c/9f1b7945526905d58b4b2d422e2ea81326dda71f9bdc9c32c8002639234a/multidict-6.9.1-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:539c2cd5fed0947c135cd7eabaaac55f48300dfa1de0f3ca4edb5efa6606f471", size = 278540, upload-time = "2026-09-21T17:58:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d9/133aa472fc1be181de29a0c2e1c4cd778280882f532b404395f646742e46/multidict-6.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3100d169ceb7bc8f05f89a6db11d1b21f119975fc26f29dc45a472e3569f0879", size = 294858, upload-time = "2026-09-21T17:58:41.791Z" }, + { url = "https://files.pythonhosted.org/packages/bd/38/21f2305dd20ac5038fa581b5d7e0d8dbbd70dc6b50231c22ec173dacddda/multidict-6.9.1-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:696477ad71385c4795e3b8e4cf10b0d2c28c2a1ca6a955e031cb1e62993e9ee3", size = 285461, upload-time = "2026-09-21T17:58:43.844Z" }, + { url = "https://files.pythonhosted.org/packages/ad/21/465a43c214d1d7e2d0c974e727d4b5935f8a0868838b5df9d0984c83c1dc/multidict-6.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f5844e7befc707367807586f550fa97e23dcfef0728f02b98c7bc498a961a5df", size = 288964, upload-time = "2026-09-21T17:58:46.16Z" }, + { url = "https://files.pythonhosted.org/packages/2d/58/72a3e8d56c1e05146d2d1841dbaacf8986375eded1e5a58d30eef191a580/multidict-6.9.1-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:abc7c2e4b47bfe6a9aea434d3fdebb9597ee636e914e92ba352f2068b9142f4c", size = 303924, upload-time = "2026-09-21T17:58:48.395Z" }, + { url = "https://files.pythonhosted.org/packages/6f/b9/911084c04530a682a54536b4f738d3992a593b5ff6fea1d6a1f02f3acc6b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7ab379f95caee071a37cbd8be86d4c65accc651d391f9f97748fef006f38769c", size = 278479, upload-time = "2026-09-21T17:58:50.494Z" }, + { url = "https://files.pythonhosted.org/packages/45/eb/9a333346c02c928a9e82534638bce85697c10c46b4901fcd70af19357356/multidict-6.9.1-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:557a4e1708df428ebe6c3081c83a273d275dad2446ce0d81fc648ac71afdb18d", size = 301650, upload-time = "2026-09-21T17:58:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/67/2f/1d936c4e45c8199db6082b6836e9182e31aa393de0de652637790831ca4b/multidict-6.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:475d04d5192eba487a3e2f935976340baa24529046e9c1c9c7a3b7bf80445ae1", size = 298024, upload-time = "2026-09-21T17:58:55.03Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0c/ca62ae2882b89a4cfeb0bbca873a4afdb5fc32994c4f8a37a7cb52ede7a0/multidict-6.9.1-cp315-cp315t-win32.whl", hash = "sha256:10083a8a0f4e1b26b599889e90b9802504ce5d3f7722f925bbb7ca47dd22a7c1", size = 57810, upload-time = "2026-09-21T17:58:57.141Z" }, + { url = "https://files.pythonhosted.org/packages/02/8b/b2c2805c3eb6fa4cdf5512fb95ac6174dde71e34dd601c5be52f4df5dc82/multidict-6.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e96ca64383efa107262ee3949f047af5ee4f1845ba09463466c04d35a83bd3ec", size = 64984, upload-time = "2026-09-21T17:58:59.446Z" }, + { url = "https://files.pythonhosted.org/packages/fd/34/03f1d204d698c408f4754c969882609b6b7a902c0e3d16a7033f219cdeda/multidict-6.9.1-cp315-cp315t-win_arm64.whl", hash = "sha256:501ed8b02a5990c67a91c732843609d43a6be1f7576fcdfc867331239f37fbd3", size = 58742, upload-time = "2026-09-21T17:59:01.726Z" }, + { url = "https://files.pythonhosted.org/packages/be/59/e26cb779be4c591d1a910f59d29aca9fba4de70349840a833beba2652371/multidict-6.9.1-py3-none-any.whl", hash = "sha256:7bf6478188f4e47bf5686e8a33da4ae28bf43b1b2528d9ee144d28492bfac60b", size = 19176, upload-time = "2026-09-21T17:59:03.501Z" }, ] [[package]] From 8ebd4dc07d30b1e3193883b0529b4582128c2bd7 Mon Sep 17 00:00:00 2001 From: Rayene Messaoud Date: Fri, 11 Sep 2026 14:45:34 +0200 Subject: [PATCH 16/30] feat(gen-shacl): translate presence-implies-value rules to SHACL-SPARQL The rules-to-SHACL-SPARQL converter recognised two named patterns. This adds the presence-implies-value pattern: a precondition asserting `value_presence: PRESENT` on one slot, and a postcondition constraining another slot with `equals_string` or `equals_string_in`. It reads as "if the guard slot is present, the target slot must be present and hold one of the allowed values", and generalises the boolean guard to arbitrary enum values. The boolean guard becomes the case `equals_string: "true"` of this pattern on an xsd:boolean-typed flag. `equals_string` / `equals_string_in` compare strings. The metamodel defines them for slots of range string; enums and types with datatype xsd:string are treated alike, and a slot without a range takes the schema's default_range. On an xsd:boolean-typed slot each value must be a lexical form of xsd:boolean (XML Schema 1.1 Part 2 section 3.3.2.2: true, false, 1, 0) and denotes that boolean. A rule applying them to any other range is skipped with a warning: its RDF values are typed literals or IRIs that equal no string. Built-in type names resolve without importing linkml:types, as in the main slot loop; the built-in name of `curie` was misspelt there and is corrected. Values are compared with `=`, spelled out as the disjunction SPARQL 1.1 section 17.4.1.9 defines `IN` to be, so the RDF 1.1-identical plain and xsd:string literal forms both match and booleans compare by value; rdflib evaluates `IN` and triple-pattern constants by term identity. COALESCE turns the type error that RDFterm-equal raises for incomparable literals into false, so a spec-conformant engine reports such a value instead of dropping the row. Queries are DISTINCT and project ?path (and ?value where there is one), so each result names the property and the offending value. A value containing a backslash followed by "u" or "U" is split with CONCAT, since codepoint escapes are replaced before a query is parsed. The exclusive-value pattern accepts the precondition `has_member: {equals_string: V}`, which states "V is one of the values". A bare `equals_string: V` is read the same way, as before, now with a warning on a multivalued slot: the specification applies a slot constraint to all members of a collection. The pattern gets the same range check and value comparison, and its maximum_cardinality > 1 form tests HAVING on the COUNT aggregate: expressions projected by SELECT are not visible to HAVING (SPARQL 1.1 section 18.2.4.2), so `HAVING (?count > N)` never held and the constraint never fired. A rule applies to all members of its class, so a class shape now carries the rules of its ancestors and mixins, as the JSON Schema generator applies them. Each rule is translated in the inheriting class's context, where slot_usage may refine the slots it references. Classes sharing a class_uri share one shape, which carries each rule once. With `open_world: true` "the postconditions may be omitted in instance data", so an absent target is no violation. A rule whose conditions carry any operator beyond what a pattern translates, or name an unknown or identifier slot, is skipped rather than partially translated. Each problem with a rule is logged once as a warning naming the declaring class, the rule's position and the class shapes it affects. The metadata fields the operator accounting ignores are derived from the metamodel. Slot resolution goes through induced slots, so slot_usage overrides, slot_uri overrides and alias-form keys resolve to the IRI sh:path emits. Behaviour changes for existing schemas: the boolean guard rejects a string "true" on an xsd:boolean flag and skips a flag whose type has another datatype IRI (main compared with str()); exclusive-value rules with maximum_cardinality > 1 now fire; inherited rules are now enforced on subclass shapes; problems with rules are logged at WARNING instead of DEBUG; rule results carry sh:resultPath and sh:value; sh:message follows the rule's in_language or default_language. Co-authored-by: jdsika --- .../generators/shacl/shacl_data_type.py | 2 +- .../linkml/src/linkml/generators/shaclgen.py | 689 +++++-- tests/linkml/test_generators/test_shaclgen.py | 1749 ++++++++++++++++- .../test_shacl_validation_plugin.py | 88 + 4 files changed, 2334 insertions(+), 194 deletions(-) diff --git a/packages/linkml/src/linkml/generators/shacl/shacl_data_type.py b/packages/linkml/src/linkml/generators/shacl/shacl_data_type.py index 1210a31be2..7ffd11d45d 100644 --- a/packages/linkml/src/linkml/generators/shacl/shacl_data_type.py +++ b/packages/linkml/src/linkml/generators/shacl/shacl_data_type.py @@ -20,7 +20,7 @@ class ShaclDataType(DataType, Enum): FLOAT = ("float", URIRef("http://www.w3.org/2001/XMLSchema#float")) DOUBLE = ("double", URIRef("http://www.w3.org/2001/XMLSchema#double")) URI = ("uri", URIRef("http://www.w3.org/2001/XMLSchema#anyURI")) - CURIE = ("curi", URIRef("http://www.w3.org/2001/XMLSchema#string")) + CURIE = ("curie", URIRef("http://www.w3.org/2001/XMLSchema#string")) NCNAME = ("ncname", URIRef("http://www.w3.org/2001/XMLSchema#string")) OBJECT_IDENTIFIER = ("objectidentifier", URIRef("http://www.w3.org/ns/shex#iri")) NODE_IDENTIFIER = ("nodeidentifier", URIRef("http://www.w3.org/ns/shex#nonLiteral")) diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 1804b1204e..c6501ddced 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -1,8 +1,9 @@ import logging import os +import re import string from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, fields import click from jsonasobj2 import JsonObj, as_dict @@ -16,7 +17,17 @@ from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor from linkml.utils.generator import Generator, shared_arguments from linkml.utils.language_tags import LanguageTagResolver -from linkml_runtime.linkml_model.meta import ClassDefinition, ElementName, PresenceEnum +from linkml_runtime.linkml_model.meta import ( + AnonymousClassExpression, + AnonymousSlotExpression, + ClassDefinition, + ClassRule, + Element, + ElementName, + PresenceEnum, + SlotDefinition, + SlotExpression, +) from linkml_runtime.utils.formatutils import underscore from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str @@ -59,6 +70,27 @@ def _validate_message_template(template: str) -> None: ) +@dataclass +class _RuleSite: + """A rule as it is translated for the shape of a class. + + *cls* is the class whose shape the rule constrains, *owner* the class + that declares it, and *index* its 1-based position among the owner's + rules; *cls* differs from *owner* when the rule is inherited. + """ + + cls: ClassDefinition + owner: str + index: int + rule: ClassRule + + def __str__(self) -> str: + text = f"Rule {self.index} of class {self.owner!r}" + if self.rule.description: + text += f" ({self.rule.description})" + return text + + @dataclass class ShaclGenerator(Generator): """Generate SHACL (Shapes Constraint Language) shapes from a LinkML schema. @@ -152,11 +184,17 @@ class ShaclGenerator(Generator): SHACL-SPARQL constraints (``sh:SPARQLConstraint``) on the corresponding ``sh:NodeShape``. Currently two patterns are recognised: - * *Boolean guard* — a precondition with ``value_presence: PRESENT`` on a - value slot and a postcondition with ``equals_string: "true"`` on a - boolean flag slot. - * *Exclusive value* — a precondition with ``equals_string`` on a slot and - a postcondition with ``maximum_cardinality`` on the *same* slot. + * *Presence implies value* — a precondition with ``value_presence: PRESENT`` + on a guard slot and a postcondition with ``equals_string`` or + ``equals_string_in`` on a target slot holding strings (an enum, or a type + with datatype ``xsd:string``) or booleans. The *boolean guard* is the + case ``equals_string: "true"`` on an ``xsd:boolean``-typed flag. + * *Exclusive value* — a precondition with ``has_member: {equals_string: V}`` + (or a bare ``equals_string: V``) on a slot and a postcondition with + ``maximum_cardinality`` on the *same* slot. + + A rule that cannot be translated exactly is skipped with a warning. A + class shape carries the rules of the class's ancestors and mixins too. See `W3C SHACL §5 `_ and `linkml/linkml#2464 `_. @@ -203,6 +241,9 @@ def serialize(self, **args) -> str: def as_graph(self) -> Graph: sv = self.schemaview + # Problems with rules, collected while the rules are translated for + # every class that has them and reported once each at the end. + self._rule_problems: dict[tuple[str, int, str], tuple[str, list[str]]] = {} g = Graph() g.bind("sh", SH) @@ -411,6 +452,7 @@ def st_node_pv(p, v): if self.emit_rules: self._add_rules(g, class_uri_with_suffix, c) + self._report_rule_problems() return g LINKML_ANY_URI = "https://w3id.org/linkml/Any" @@ -420,227 +462,509 @@ def st_node_pv(p, v): # ------------------------------------------------------------------- def _add_rules(self, g: Graph, shape_uri: URIRef, cls: ClassDefinition) -> None: - """Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks. + """Emit ``sh:sparql`` constraints from the LinkML ``rules`` that apply to *cls*. Each recognised rule is converted into an ``sh:SPARQLConstraint`` - attached to *shape_uri*. Unrecognised patterns are logged at - ``DEBUG`` level and silently skipped. - - Currently recognised patterns: - - * **Boolean guard** — a *precondition* with - ``value_presence: PRESENT`` on a value slot and a *postcondition* - with ``equals_string: "true"`` on a boolean flag slot. - - * **Exclusive value** — a *precondition* with ``equals_string`` on - a slot and a *postcondition* with ``maximum_cardinality`` on the - *same* slot. Enforces that when a specific value is present in a - multivalued slot, the total number of values must not exceed the - given cardinality (typically 1 for mutual exclusion). + attached to *shape_uri*. Currently recognised patterns: + + * **Presence implies value** — a *precondition* with + ``value_presence: PRESENT`` on a guard slot and a *postcondition* + with ``equals_string`` or ``equals_string_in`` on a target slot. + When the guard is present, the target must be present and hold one + of the allowed values. The target must hold strings (an enum, or a + type with datatype ``xsd:string``) or booleans (``xsd:boolean``). + The **boolean guard** is the case ``equals_string: "true"`` on a + boolean flag. + + * **Exclusive value** — a *precondition* with ``has_member: + {equals_string: V}`` on a slot and a *postcondition* with + ``maximum_cardinality`` on the *same* slot. When V is one of the + slot's values, the slot holds at most that many values (typically 1 + for mutual exclusion). A bare ``equals_string: V`` precondition on a + multivalued slot is read the same way, with a warning, although the + specification applies ``equals_string`` to all members of a + collection. + + Apart from that reading, every emitted constraint translates its rule + exactly. A rule that cannot be translated exactly is skipped with a + warning naming the reason: an operator combination outside these + patterns, a condition on an unknown or identifier slot, a value the + target's range cannot hold (see :meth:`_value_terms`), or + ``bidirectional``. Of a rule with ``elseconditions`` the forward + (if/then) direction is emitted, exactly, and a warning reports the + else branch as not enforced. + + A rule applies to "all members of this class" (metamodel ``rules``), + so the shape of a class carries the rules of its ancestors and mixins + as well, as it carries their slots, and as the JSON Schema generator + applies them. Each rule is translated in the context of *cls*, where + ``slot_usage`` may refine a slot the rule references. See `W3C SHACL §5 `_. """ - if not cls.rules: - return - sv = self.schemaview - for rule in cls.rules: - if getattr(rule, "deactivated", False): - continue - - if getattr(rule, "bidirectional", False): - logger.warning( - "Rule in class %r has bidirectional=true; " - "SHACL-SPARQL generation does not support bidirectional rules. " - "Skipping this rule entirely.", - cls.name, - ) - continue - - if getattr(rule, "open_world", False): - logger.warning( - "Rule in class %r has open_world=true; " - "SHACL operates under closed-world assumption. " - "The constraint is emitted but may not match open-world semantics.", - cls.name, - ) + for owner in sv.class_ancestors(cls.name): + for index, rule in enumerate(sv.get_class(owner).rules, start=1): + self._add_rule(g, shape_uri, _RuleSite(cls, owner, index, rule)) + + def _add_rule(self, g: Graph, shape_uri: URIRef, site: _RuleSite) -> None: + """Emit the ``sh:SPARQLConstraint`` of the rule at *site* on *shape_uri*, unless it is skipped.""" + rule = site.rule + if rule.deactivated: + return + if rule.bidirectional: + self._skip_rule(site, "bidirectional rules are not supported") + return + sparql_query = self._rule_to_sparql(site) + if sparql_query is None: + return + if rule.elseconditions is not None: + self._warn_rule( + site, "its elseconditions are not enforced; SHACL-SPARQL generation emits the if/then direction only" + ) + message = Literal(rule.description, lang=self._resolve_language(rule)) if rule.description else None + if self._has_sparql_constraint(g, shape_uri, Literal(sparql_query), message): + return - if getattr(rule, "elseconditions", None): - logger.warning( - "Rule in class %r has elseconditions; " - "only the forward (if/then) branch is emitted as sh:sparql. " - "The else branch cannot be represented in SHACL-SPARQL.", - cls.name, - ) + constraint = BNode() + g.add((shape_uri, SH.sparql, constraint)) + g.add((constraint, RDF.type, SH.SPARQLConstraint)) + if message is not None: + g.add((constraint, SH.message, message)) + g.add((constraint, SH.select, Literal(sparql_query))) - sparql_query = self._rule_to_sparql(sv, cls, rule) - if sparql_query is None: - logger.debug( - "Skipping unsupported rule pattern in class %r: %s", - cls.name, - getattr(rule, "description", "(no description)"), - ) - continue + @staticmethod + def _has_sparql_constraint(g: Graph, shape_uri: URIRef, query: Literal, message: Literal | None) -> bool: + """Whether the shape *shape_uri* already carries the constraint with *query* and *message*. - constraint = BNode() - g.add((shape_uri, SH.sparql, constraint)) - g.add((constraint, RDF.type, SH.SPARQLConstraint)) + Classes that share a ``class_uri`` share one shape when shapes are named + by ``class_uri`` (the default), so a rule they all inherit would + otherwise be added once per class and reported twice. + """ + return any( + (constraint, SH.select, query) in g and set(g.objects(constraint, SH.message)) == {message} - {None} + for constraint in g.objects(shape_uri, SH.sparql) + ) - message = getattr(rule, "description", None) - if message: - g.add((constraint, SH.message, Literal(message))) + def _warn_rule(self, site: _RuleSite, problem: str) -> None: + """Record *problem* with the rule at *site* for the shape of ``site.cls``. - g.add((constraint, SH.select, Literal(sparql_query))) + A rule is translated once for every class that has it, declared or + inherited, so the same problem can arise several times; it is reported + once, by :meth:`_report_rule_problems`. + """ + _, classes = self._rule_problems.setdefault((site.owner, site.index, problem), (str(site), [])) + if site.cls.name not in classes: + classes.append(site.cls.name) + + def _report_rule_problems(self) -> None: + """Log each recorded rule problem once, naming the class shapes it affects + unless that is only the class declaring the rule.""" + for (owner, _, problem), (rule, classes) in self._rule_problems.items(): + shapes = "" if classes == [owner] else f" (in the shapes of {', '.join(map(repr, classes))})" + logger.warning("%s: %s%s.", rule, problem, shapes) + + def _skip_rule(self, site: _RuleSite, reason: str) -> None: + """Log that the rule at *site* is not translated, and why.""" + self._warn_rule(site, f"skipped, because {reason}") + + # Fields on a slot condition / class expression that carry no constraint + # semantics: they never change which instances satisfy the condition, so + # they are ignored by the operator accounting below. Anything set on a + # condition that is neither here nor explicitly translated by a converter + # makes the rule untranslatable — the converters must SKIP such a rule + # rather than emit a query that silently drops a conjunct (which would + # widen the trigger or narrow the check: a mis-translation, not a skip). + # Derived from the metamodel: the metadata every ``element`` carries, minus + # anything that is a ``slot_expression`` operator. + _NON_OPERATOR_FIELDS = frozenset(f.name for f in fields(Element)) - frozenset( + f.name for f in fields(SlotExpression) + ) - def _rule_to_sparql(self, sv, cls: ClassDefinition, rule) -> str | None: - """Convert a ``ClassRule`` to a SPARQL SELECT query string. + # The lexical space of xsd:boolean and the value each lexical form maps to + # (XML Schema 1.1 Part 2 §3.3.2.2, ), + # as SPARQL boolean literals. + _XSD_BOOLEAN_LEXICAL = {"true": "true", "1": "true", "false": "false", "0": "false"} + + _NO_PATTERN = "its conditions match none of the translated patterns (presence implies value, exclusive value)" + + @classmethod + def _set_operator_fields( + cls, condition: SlotDefinition | AnonymousSlotExpression | AnonymousClassExpression + ) -> set[str]: + """Return the names of the constraint-bearing fields actually set on a + rule condition or class expression. + + A field counts as *set* when it is not ``None`` and not an empty + collection (SchemaView materialises unset multivalued fields as empty + lists / dicts). Scalars are never judged by truthiness, so legitimate + falsy constraints such as ``minimum_value: 0`` or + ``equals_string: ""`` still count as set. Metadata fields + (:data:`_NON_OPERATOR_FIELDS`) are excluded. + + The converters compare this set against the exact operator set they + translate and skip the rule on any mismatch, so an unrecognised (or + future-metamodel) operator can never be silently dropped. + """ + return { + name + for name, value in vars(condition).items() + if not name.startswith("_") + and name not in cls._NON_OPERATOR_FIELDS + and value is not None + and not (isinstance(value, list | dict) and not value) + } - Returns ``None`` when the rule does not match any supported pattern. + def _rule_to_sparql(self, site: _RuleSite) -> str | None: + """Translate the rule at *site* to a SPARQL SELECT query. + + Returns ``None``, after a warning naming the reason, when the rule + matches no supported pattern exactly. Each pattern requires its + conditions to set **exactly** the operators it translates; a rule whose + pre/postconditions carry anything more (extra scalar operators, + expression-level ``any_of``/``all_of``/``none_of``/``exactly_one_of``, + ...) is skipped rather than partially translated, since dropping a term + would widen the precondition (false positives) or weaken the + postcondition (false negatives). """ - pre = getattr(rule, "preconditions", None) - post = getattr(rule, "postconditions", None) - if not pre or not post: + pre, post = site.rule.preconditions, site.rule.postconditions + if pre is None or post is None: + self._skip_rule(site, "only rules with preconditions and postconditions are translated") + return None + for side, expression in (("preconditions", pre), ("postconditions", post)): + untranslated = self._set_operator_fields(expression) - {"slot_conditions"} + if untranslated: + self._skip_rule(site, f"its {side} use {', '.join(sorted(untranslated))}") + return None + if len(pre.slot_conditions) != 1 or len(post.slot_conditions) != 1: + self._skip_rule(site, self._NO_PATTERN) return None - pre_slots = getattr(pre, "slot_conditions", None) or {} - post_slots = getattr(post, "slot_conditions", None) or {} + ((pre_name, pre_cond),) = pre.slot_conditions.items() + ((post_name, post_cond),) = post.slot_conditions.items() + pre_slot = self._rule_condition_slot(site, pre_name) + post_slot = self._rule_condition_slot(site, post_name) if pre_slot is not None else None + if pre_slot is None or post_slot is None: + return None + pre_ops = self._set_operator_fields(pre_cond) + post_ops = self._set_operator_fields(post_cond) + + # Presence implies value: "if the guard is present, the target must be + # present and hold one of the allowed values". The boolean guard is + # the case `equals_string: "true"` on a boolean flag. + if ( + pre_ops == {"value_presence"} + and pre_cond.value_presence == PresenceEnum(PresenceEnum.PRESENT) + and post_ops in ({"equals_string"}, {"equals_string_in"}) + ): + values = [post_cond.equals_string] if post_ops == {"equals_string"} else list(post_cond.equals_string_in) + terms = self._value_terms(site, post_slot, values) + if terms is None: + return None + return self._build_presence_implies_value_sparql(pre_slot, post_slot, terms, bool(site.rule.open_world)) + + # Exclusive value: "if V is one of the values of slot X, then X has at + # most N values". + if post_ops == {"maximum_cardinality"} and pre_slot.name == post_slot.name: + exclusive = self._exclusive_value(site, pre_slot, pre_cond, pre_ops) + if exclusive is not None: + terms = self._value_terms(site, pre_slot, [exclusive]) + if terms is None: + return None + return self._build_exclusive_value_sparql(pre_slot, terms[0], int(post_cond.maximum_cardinality)) + + self._skip_rule(site, self._NO_PATTERN) + return None - # Pattern: boolean guard - # preconditions: exactly one slot with value_presence PRESENT - # postconditions: exactly one slot with equals_string "true" - if len(pre_slots) == 1 and len(post_slots) == 1: - pre_slot_name = next(iter(pre_slots)) - post_slot_name = next(iter(post_slots)) + def _exclusive_value( + self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, operators: set[str] + ) -> str | None: + """The value V of an exclusive-value precondition on *slot*, or ``None`` if *condition* is not one. + + ``has_member: {equals_string: V}`` states "V is one of the values" + (metamodel ``has_member``: "at least one member satisfying the + condition"). A bare ``equals_string: V`` on a multivalued slot is read + the same way, as the pattern always has, with a warning: the + specification applies a slot constraint to all members of a collection + (``05validation.md``), under which the rule would mean something else. + """ + if operators == {"has_member"} and self._set_operator_fields(condition.has_member) == {"equals_string"}: + return condition.has_member.equals_string + if operators == {"equals_string"}: + if slot.multivalued: + self._warn_rule( + site, + f"its precondition equals_string on the multivalued slot {slot.name!r} is read as " + "'one of the values equals', which has_member: {equals_string: ...} states explicitly; " + "the specification applies equals_string to all members of a collection", + ) + return condition.equals_string + return None - pre_cond = pre_slots[pre_slot_name] - post_cond = post_slots[post_slot_name] + def _rule_slot(self, cls: ClassDefinition, slot_name: str) -> SlotDefinition | None: + """Resolve a rule condition's slot key to the slot it names, or ``None`` + when no such slot exists. - # Note: PresenceEnum.PRESENT is a PermissibleValue, but parsed schemas - # return PresenceEnum instances — wrapping ensures type-compatible comparison. - is_value_present = getattr(pre_cond, "value_presence", None) == PresenceEnum(PresenceEnum.PRESENT) - is_flag_true = getattr(post_cond, "equals_string", None) == "true" + Resolution order mirrors ``sh:path`` in the main slot loop: the induced + (class-specific) slot when the key names one of the class's slots, then + the underscored alias form (a rule key ``my_slot`` for a slot named + ``my slot`` — SchemaView normalises names the same way elsewhere), then + the base slot. + """ + sv = self.schemaview + class_slot_names = sv.class_slots(cls.name) + if slot_name in class_slot_names: + return sv.induced_slot(slot_name, cls.name) + canonical = next((s for s in class_slot_names if underscore(s) == underscore(slot_name)), None) + if canonical is not None: + return sv.induced_slot(canonical, cls.name) + return sv.get_slot(slot_name) + + def _rule_condition_slot(self, site: _RuleSite, slot_name: str) -> SlotDefinition | None: + """The slot a condition of the rule at *site* names, or ``None`` (rule skipped) when it cannot be queried. + + An unknown name would make the query use a predicate no shape or data + uses. An identifier slot is the node's IRI, not a property arc (the + main slot loop does not require it either), so a condition on it can + never match. + """ + slot = self._rule_slot(site.cls, slot_name) + if slot is None: + self._skip_rule(site, f"its condition names {slot_name!r}, which is not a slot") + return None + if slot.identifier: + self._skip_rule( + site, f"its condition is on the identifier slot {slot_name!r}, which is the node IRI, not a property" + ) + return None + return slot - if is_value_present and is_flag_true: - return self._build_boolean_guard_sparql(sv, cls, post_slot_name, pre_slot_name) + def _slot_range(self, slot: SlotDefinition) -> ElementName | None: + """The range of *slot*, defaulting to the schema's ``default_range`` as an induced slot does.""" + return slot.range or self.schemaview.schema.default_range - # Pattern: exclusive value - # preconditions: slot X has equals_string (a specific enum value) - # postconditions: same slot X has maximum_cardinality N - # Semantics: "If value V is present in slot X, then X has at most N values." - pre_equals = getattr(pre_cond, "equals_string", None) - post_max_card = getattr(post_cond, "maximum_cardinality", None) + def _slot_iri(self, slot: SlotDefinition) -> str: + """The full IRI of *slot*, exactly as ``sh:path`` in the main slot loop renders it. + + An induced slot carries its ``slot_usage`` overrides, so an overridden + ``slot_uri`` yields the same IRI as ``sh:path``; otherwise the query + would use a property the data never uses and never fire. + """ + sv = self.schemaview + if slot.name in sv.element_by_schema_map(): + return sv.get_uri(slot, expand=True) + return sv.expand_curie(f"{sv.schema.default_prefix}:{underscore(slot.name)}") - if pre_equals is not None and post_max_card is not None and pre_slot_name == post_slot_name: - return self._build_exclusive_value_sparql(sv, cls, pre_slot_name, pre_equals, int(post_max_card)) + def _type_uri(self, r: ElementName | None) -> str | None: + """The expanded datatype IRI of type range *r*, or ``None`` when *r* is not a type. + Resolved through the induced type, so a type derived with ``typeof`` + inherits the ``uri`` of its ancestor. A built-in type name in a schema + that does not import ``linkml:types`` resolves as the main slot loop + resolves it (:class:`ShaclDataType`). + """ + sv = self.schemaview + if r in sv.all_types(): + return sv.get_uri(sv.induced_type(r), expand=True) + builtin = next((t for t in ShaclDataType if t.linkml_type == r), None) + return str(builtin.uri_ref) if builtin is not None else None + + def _is_string_range(self, r: ElementName | None) -> bool: + """Whether a slot with range *r* holds strings, which ``equals_string`` compares against. + + True for an enum, whose permissible values are rendered as their + ``meaning`` IRI or as a plain literal (as :meth:`_add_enum` renders + them); for a type whose datatype is ``xsd:string``, whose values are + plain literals; and for no range at all, whose values are untyped and + compared as strings, as the JSON Schema generator compares them. A + type with any other datatype, including one derived from ``string`` + (``xsd:anyURI``, ``xsd:token``, ...), holds typed literals or IRIs + that a string literal does not match. + """ + if r is None or r in self.schemaview.all_enums(): + return True + return self._type_uri(r) == str(XSD.string) + + def _value_terms(self, site: _RuleSite, slot: SlotDefinition, values: list[str]) -> list[str] | None: + """The SPARQL terms of the equals_string(_in) *values* on *slot*, or ``None`` (rule skipped). + + The metamodel defines both operators for slots of range ``string``. + Enums, whose values are strings in instance data, and types with + datatype ``xsd:string`` are treated alike (:meth:`_string_value_term`). + A slot whose range is ``xsd:boolean``-typed holds booleans, as the + boolean guard compares them: each value must be a lexical form of + ``xsd:boolean`` and becomes the boolean it denotes, compared by value. + On any other range the RDF data holds typed literals + (``"3"^^xsd:integer``) or IRIs that no string equals, so the + constraint would report conforming data or never fire. + """ + r = self._slot_range(slot) + if self._is_string_range(r): + return [self._string_value_term(site, slot, value) for value in values] + if self._type_uri(r) == str(XSD.boolean): + terms = [self._XSD_BOOLEAN_LEXICAL.get(value) for value in values] + if None not in terms: + return terms + self._skip_rule( + site, + f"equals_string(_in) on the boolean slot {slot.name!r} with {values!r}, " + "which are not all xsd:boolean lexical forms (true, false, 1, 0)", + ) + return None + self._skip_rule( + site, + f"equals_string(_in) on slot {slot.name!r}, whose range {r!r} is neither an enum " + "nor a type with datatype xsd:string or xsd:boolean", + ) return None - def _build_boolean_guard_sparql(self, sv, cls: ClassDefinition, flag_slot_name: str, value_slot_name: str) -> str: - """Build a SPARQL SELECT query for the boolean-guard pattern. + def _string_value_term(self, site: _RuleSite, slot: SlotDefinition, value: str) -> str: + """The SPARQL term of the ``equals_string`` *value* of string-valued *slot*. + + A permissible value of an enum range is rendered as :meth:`_add_enum` + renders it, as the IRI of its ``meaning`` where it has one; anything + else is a string literal. A value that is not a permissible value of + an enum with static permissible values is reported: no valid value of + the slot can equal it. + """ + sv = self.schemaview + r = self._slot_range(slot) + if r in sv.all_enums(): + permissible_values = sv.get_enum(r).permissible_values + pv = permissible_values.get(value) + if pv is not None and pv.meaning: + return f"<{sv.expand_curie(pv.meaning)}>" + if pv is None and permissible_values: + self._warn_rule( + site, + f"it compares slot {slot.name!r} with {value!r}, which is not a permissible value of enum {r!r}", + ) + return self._sparql_string_literal(value) + + @staticmethod + def _sparql_string_literal(value: str) -> str: + """Render *value* as a SPARQL expression for that string. + + The characters the grammar forbids raw are escaped (`SPARQL 1.1 §19.7 + `_), so a value + with a double quote, backslash or newline neither breaks the + ``sh:select`` query nor injects into it. Codepoint escapes are replaced + before parsing (`§19.2 `_), + so an escaped backslash followed by ``u`` / ``U`` would be read as one: + the literal is split there and rejoined with ``CONCAT``. + """ + escaped = ( + str(value) + .replace("\\", "\\\\") + .replace('"', '\\"') + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + parts = re.split(r"(?<=\\)(?=[uU])", escaped) + if len(parts) == 1: + return f'"{escaped}"' + return "CONCAT(" + ", ".join(f'"{part}"' for part in parts) + ")" + + @staticmethod + def _sparql_is_one_of(var: str, terms: list[str]) -> str: + """A SPARQL expression that is true when *var* equals one of *terms*, and false otherwise. + + The test is spelled out as ``var = t1 || var = t2 || ...``, which is how + `SPARQL 1.1 §17.4.1.9 `_ + defines ``IN``. The ``=`` operator compares values, so a plain literal + also matches its RDF 1.1-identical ``xsd:string`` form (`RDF 1.1 + Concepts §3.3 `_), + which JSON-LD produces under ``"@type": "xsd:string"`` coercion; some + engines, rdflib among them, match ``IN`` and triple-pattern constants by + term identity and miss that form. ``COALESCE`` (§17.4.1.3) turns the + type error that ``RDFterm-equal`` (§17.4.1.7) raises for incomparable + literals into false: such a value equals none of *terms*, and an + unguarded error would drop the row and hide the violation. An unbound + *var* is false as well. + """ + disjunction = " || ".join(f"{var} = {term}" for term in terms) + return f"COALESCE( {disjunction}, false )" + + def _build_presence_implies_value_sparql( + self, guard: SlotDefinition, target: SlotDefinition, terms: list[str], open_world: bool + ) -> str: + """Build the SPARQL SELECT query of the presence-implies-value pattern. - The query detects violations where the value property is present - but the boolean flag is absent or not ``true``. + A focus node violates the rule when the *guard* slot is present and the + *target* slot is absent or holds a value that equals none of the SPARQL + *terms* (see :meth:`_sparql_is_one_of`). The boolean guard is this + pattern with the boolean ``true`` as the only term. With *open_world*, + "the postconditions may be omitted in instance data" (metamodel + ``open_world``), so an absent target is no violation. Conforms to `SHACL §5.3.1 - `_: - ``$this`` is pre-bound to each focus node. + `_ (``$this`` + is pre-bound to each focus node) and projects ``?path`` and ``?value`` + as result variables (`§5.3.2 + `_), so each + result names the target property and the offending value. """ - flag_uri = self._slot_uri(sv, flag_slot_name, cls) - value_uri = self._slot_uri(sv, value_slot_name, cls) - + guard_iri = self._slot_iri(guard) + target_iri = self._slot_iri(target) + is_allowed = self._sparql_is_one_of("?value", terms) + if open_world: + target_pattern = f"$this <{target_iri}> ?value ." + violation = f"!{is_allowed}" + else: + target_pattern = f"OPTIONAL {{ $this <{target_iri}> ?value . }}" + violation = f"!BOUND(?value) || !{is_allowed}" return ( - f"SELECT $this WHERE {{\n" - f" OPTIONAL {{ $this <{flag_uri}> ?flag . }}\n" - f" OPTIONAL {{ $this <{value_uri}> ?value . }}\n" - f" FILTER (\n" - f' ( !BOUND(?flag) || str(?flag) != "true" ) &&\n' - f" BOUND(?value)\n" - f" )\n" + f"SELECT DISTINCT $this (<{target_iri}> AS ?path) ?value WHERE {{\n" + f" $this <{guard_iri}> ?guard .\n" + f" {target_pattern}\n" + f" FILTER ( {violation} )\n" f"}}" ) - def _build_exclusive_value_sparql( - self, - sv, - cls: ClassDefinition, - slot_name: str, - value_name: str, - max_card: int, - ) -> str | None: - """Build a SPARQL SELECT query for the exclusive-value pattern. - - Detects violations where a specific value is present in a multivalued - slot but the total number of values exceeds *max_card*. - - For the common case ``max_card == 1``, the query checks whether the - exclusive value coexists with any other value (simple existence test). - For ``max_card > 1``, a subquery counts all values and checks against - the limit. + def _build_exclusive_value_sparql(self, slot: SlotDefinition, term: str, max_card: int) -> str: + """Build the SPARQL SELECT query of the exclusive-value pattern. - The exclusive value is resolved to its full IRI via the slot's enum - ``meaning`` field. If the slot is not an enum or the value has no - ``meaning``, the value is compared as a plain literal. + A focus node violates the rule when *slot* holds a value equal to the + SPARQL *term* (see :meth:`_sparql_is_one_of`) and more than *max_card* + values in total. For ``max_card == 1`` the query reports each other + value that coexists with the exclusive one. For ``max_card > 1`` a + subquery counts all values, testing ``HAVING`` on the ``COUNT`` + aggregate itself: expressions projected by ``SELECT`` are not visible + to ``HAVING`` (`SPARQL 1.1 §18.2.4.2 + `_), so a + projected alias would be unbound there and the constraint never fire. Conforms to `SHACL §5.3.1 - `_: - ``$this`` is pre-bound to each focus node. + `_ and + projects ``?path`` (and, for ``max_card == 1``, the coexisting value as + ``?value``) as result variables (`§5.3.2 + `_). """ - slot_uri = self._slot_uri(sv, slot_name, cls) - value_ref = self._resolve_enum_value_ref(sv, slot_name, value_name) - + iri = self._slot_iri(slot) + is_exclusive = self._sparql_is_one_of("?exclusive", [term]) if max_card == 1: + is_other_exclusive = self._sparql_is_one_of("?other", [term]) return ( - f"SELECT $this WHERE {{\n" - f" $this <{slot_uri}> {value_ref} .\n" - f" $this <{slot_uri}> ?other .\n" - f" FILTER (?other != {value_ref})\n" + f"SELECT DISTINCT $this (<{iri}> AS ?path) (?other AS ?value) WHERE {{\n" + f" $this <{iri}> ?exclusive .\n" + f" $this <{iri}> ?other .\n" + f" FILTER ( {is_exclusive} && !{is_other_exclusive} )\n" f"}}" ) - return ( - f"SELECT $this WHERE {{\n" - f" $this <{slot_uri}> {value_ref} .\n" + f"SELECT DISTINCT $this (<{iri}> AS ?path) WHERE {{\n" + f" $this <{iri}> ?exclusive .\n" + f" FILTER ( {is_exclusive} )\n" f" {{\n" - f" SELECT $this (COUNT(?val) AS ?count)\n" - f" WHERE {{ $this <{slot_uri}> ?val . }}\n" + f" SELECT $this\n" + f" WHERE {{ $this <{iri}> ?item . }}\n" f" GROUP BY $this\n" - f" HAVING (?count > {max_card})\n" + f" HAVING ( COUNT(?item) > {max_card} )\n" f" }}\n" f"}}" ) - def _resolve_enum_value_ref(self, sv, slot_name: str, value_name: str) -> str: - """Resolve an enum value name to a SPARQL term (IRI or literal). - - Looks up the slot's range as an enum, finds the permissible value - matching *value_name*, and returns its ``meaning`` as a full IRI - wrapped in angle brackets. Falls back to a quoted literal if the - slot is not an enum or the value lacks a ``meaning``. - """ - slot = sv.get_slot(slot_name) - if slot: - range_name = slot.range - if range_name and range_name in sv.all_enums(): - enum = sv.get_enum(range_name) - pv = enum.permissible_values.get(value_name) - if pv and pv.meaning: - iri = sv.expand_curie(pv.meaning) - return f"<{iri}>" - return f'"{value_name}"' - - def _slot_uri(self, sv, slot_name: str, cls: ClassDefinition) -> str: - """Resolve a slot name to a full IRI string for use in SPARQL queries. - - Mirrors the resolution logic used for ``sh:path`` in the main slot loop: - prefer ``sv.get_uri()`` for slots registered in the schema map, fall - back to ``default_prefix:underscored_name``. - """ - slot = sv.get_slot(slot_name) - if slot and slot_name in sv.element_by_schema_map(): - return sv.get_uri(slot, expand=True) - pfx = sv.schema.default_prefix - return sv.expand_curie(f"{pfx}:{underscore(slot_name)}") - def _add_class(self, func: Callable, r: ElementName) -> None: """Add a class/shape constraint for range class *r*. @@ -927,8 +1251,9 @@ def add_simple_data_type(func: Callable, r: ElementName) -> None: show_default=True, help=( "Emit sh:sparql constraints from LinkML rules: blocks. " - "When enabled (default), recognised rule patterns (e.g. boolean-guard) " - "are translated into SHACL-SPARQL constraints on the corresponding " + "When enabled (default), recognised rule patterns (boolean-guard, " + "presence-implies-value, exclusive-value) are translated into " + "SHACL-SPARQL constraints on the corresponding " "sh:NodeShape. Use --no-emit-rules to suppress rule generation." ), ) diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index f367de97e7..2339b941e5 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -1,9 +1,12 @@ +import json +import logging +import re from collections import Counter from typing import Any import pytest import rdflib -from rdflib import RDF, RDFS, SH, Literal, URIRef +from rdflib import RDF, RDFS, SH, XSD, Literal, URIRef from rdflib.collection import Collection from linkml.generators.shacl.shacl_data_type import ShaclDataType @@ -2108,7 +2111,7 @@ def test_rule_deactivated_skipped(): def test_rule_unsupported_pattern_skipped(): - """Unrecognised rule patterns are silently skipped (no sh:sparql emitted).""" + """Unrecognised rule patterns are skipped (no sh:sparql emitted).""" g = _parse_shacl(_UNSUPPORTED_RULE_SCHEMA_YAML) shape = URIRef("https://example.org/unsupported-test/TestClass") @@ -2585,13 +2588,14 @@ def test_exclusive_value_sparql_uses_enum_iri(): def test_exclusive_value_max_card_1_sparql_structure(): - """For maximum_cardinality: 1, SPARQL uses FILTER(?other != ). + """For maximum_cardinality: 1, SPARQL reports each ?other value coexisting with . The query pattern for N=1 is: - SELECT $this WHERE { - $this . + SELECT DISTINCT $this ( AS ?path) (?other AS ?value) WHERE { + $this ?exclusive . $this ?other . - FILTER (?other != ) + FILTER ( COALESCE( ?exclusive = , false ) && + !COALESCE( ?other = , false ) ) } This is more efficient than the COUNT-based approach for the common @@ -2616,13 +2620,14 @@ def test_exclusive_value_max_card_gt1_sparql_structure(): """For maximum_cardinality > 1, SPARQL uses COUNT-based subquery. The query pattern for N>1 is: - SELECT $this WHERE { - $this . + SELECT DISTINCT $this ( AS ?path) WHERE { + $this ?exclusive . + FILTER ( COALESCE( ?exclusive = , false ) ) { - SELECT $this (COUNT(?val) AS ?count) - WHERE { $this ?val . } + SELECT $this + WHERE { $this ?item . } GROUP BY $this - HAVING (?count > N) + HAVING ( COUNT(?item) > N ) } } """ @@ -2833,3 +2838,1725 @@ def test_shacl_modular_schema_with_reused_attribute_name(tmp_path) -> None: graph.parse(data=ShaclGenerator(str(domain)).serialize(), format="turtle") shapes = set(graph.subjects(RDF.type, SH.NodeShape)) assert URIRef("https://example.org/domain/Pedido") in shapes + + +# =========================================================================== +# Presence-implies-value pattern tests (enum guard) +# =========================================================================== +# +# The "presence implies value" pattern generalises the boolean guard to +# enum-valued targets. It translates a LinkML rule where: +# - preconditions: a guard slot has value_presence: PRESENT +# - postconditions: a target slot has equals_string (single required value) +# or equals_string_in (a set of acceptable values) +# +# Semantics: "If the guard slot is present, the target slot must be present +# and hold one of the allowed values", e.g. "if a document has a signature, +# its status must be Published". +# +# References: +# - W3C SHACL §5 +# - W3C SHACL §5.3.1 +# - W3C SHACL §5.3.2 +# =========================================================================== + +_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML = """ +id: https://example.org/presence-implies-value +name: presence_implies_value_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/presence-implies-value/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +enums: + StatusEnum: + permissible_values: + Draft: + meaning: ex:Draft + Reviewed: + meaning: ex:Reviewed + Approved: + meaning: ex:Approved + Published: + meaning: ex:Published + + ModeEnum: + permissible_values: + Auto: + description: Automatic mode (no meaning IRI). + Manual: + description: Manual mode (no meaning IRI). + +slots: + status: + range: StatusEnum + slot_uri: ex:status + signature: + range: string + slot_uri: ex:signature + review_score: + range: float + slot_uri: ex:review_score + mode: + range: ModeEnum + slot_uri: ex:mode + manual_value: + range: decimal + slot_uri: ex:manual_value + +classes: + Document: + class_uri: ex:Document + slots: + - status + - signature + - review_score + rules: + - description: If a document has a signature, its status must be Published. + preconditions: + slot_conditions: + signature: + value_presence: PRESENT + postconditions: + slot_conditions: + status: + equals_string: "Published" + - description: If a document has a review score, its status must be Reviewed or Approved. + preconditions: + slot_conditions: + review_score: + value_presence: PRESENT + postconditions: + slot_conditions: + status: + equals_string_in: + - Reviewed + - Approved + + Device: + class_uri: ex:Device + slots: + - mode + - manual_value + rules: + - description: If manual_value is provided, mode must be Manual (literal fallback). + preconditions: + slot_conditions: + manual_value: + value_presence: PRESENT + postconditions: + slot_conditions: + mode: + equals_string: "Manual" +""" + +EX_PIV = rdflib.Namespace("https://example.org/presence-implies-value/") + + +def _rule_results(schema: str, data: str | rdflib.Graph) -> tuple[bool, list[tuple]]: + """Validate *data* against the shapes generated for *schema*. + + *data* is Turtle or a graph. Returns whether the data conforms overall, + and the ``(focus node, result path, value)`` of each result that a rule + constraint (``sh:sparql``) reports, so that a property-shape result + (datatype, cardinality, ...) can neither mask nor stand in for what a rule + decides. + """ + import pyshacl + + shapes = rdflib.Graph().parse(data=ShaclGenerator(schema, mergeimports=False).serialize(), format="turtle") + data_graph = data if isinstance(data, rdflib.Graph) else rdflib.Graph().parse(data=data, format="turtle") + conforms, report, _ = pyshacl.validate(data_graph, shacl_graph=shapes, advanced=True) + results = [ + (report.value(result, SH.focusNode), report.value(result, SH.resultPath), report.value(result, SH.value)) + for result in report.subjects(SH.sourceConstraintComponent, SH.SPARQLConstraintComponent) + ] + return conforms, results + + +def _validate_rules(schema: str, data: str | rdflib.Graph) -> tuple[bool, set]: + """Whether *data* conforms to the shapes of *schema*, and the focus nodes its rule constraints report.""" + conforms, results = _rule_results(schema, data) + return conforms, {focus_node for focus_node, _, _ in results} + + +def _sparql_queries(g: rdflib.Graph, shape: URIRef) -> list[str]: + """The ``sh:select`` query of every ``sh:sparql`` constraint on *shape*.""" + return [str(query) for node in g.objects(shape, SH.sparql) for query in g.objects(node, SH.select)] + + +def test_presence_implies_value_generates_sparql(): + """Presence-implies-value rules produce sh:sparql constraints on the NodeShape.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + shape = EX_PIV.Document + sparql_nodes = list(g.objects(shape, SH.sparql)) + assert len(sparql_nodes) == 2, f"Expected 2 sh:sparql constraints, got {len(sparql_nodes)}" + + for node in sparql_nodes: + assert (node, RDF.type, SH.SPARQLConstraint) in g + selects = list(g.objects(node, SH.select)) + assert len(selects) == 1, "Each constraint must have exactly one sh:select" + query = str(selects[0]) + assert "$this" in query, "SPARQL must use $this pre-bound variable" + assert "?value = " in query, "presence-implies-value SPARQL must compare the target by value" + assert "FILTER" in query, "SPARQL must have a FILTER clause" + + +def test_presence_implies_value_single_uses_enum_iri(): + """A single equals_string target resolves to the enum meaning IRI.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + signature_query = [q for q in _sparql_queries(g, EX_PIV.Document) if str(EX_PIV.signature) in q] + assert len(signature_query) == 1, "Expected exactly one signature rule" + query = signature_query[0] + + assert str(EX_PIV.status) in query + assert f"<{EX_PIV.Published}>" in query, f"Expected Published IRI, got:\n{query}" + + +def test_presence_implies_value_set_uses_all_iris(): + """equals_string_in resolves every allowed value to its enum meaning IRI.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + review_query = [q for q in _sparql_queries(g, EX_PIV.Document) if str(EX_PIV.review_score) in q] + assert len(review_query) == 1, "Expected exactly one review_score rule" + query = review_query[0] + + assert f"<{EX_PIV.Reviewed}>" in query, f"Expected Reviewed IRI, got:\n{query}" + assert f"<{EX_PIV.Approved}>" in query, f"Expected Approved IRI, got:\n{query}" + + +def test_presence_implies_value_no_meaning_falls_back_to_literal(): + """When the target enum value lacks a meaning IRI, it is compared as a literal.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + queries = _sparql_queries(g, EX_PIV.Device) + assert len(queries) == 1 + query = queries[0] + assert '"Manual"' in query, f"No-meaning enum should use literal '\"Manual\"', got:\n{query}" + assert f"<{EX_PIV}Manual>" not in query, "Should not emit as IRI when meaning is absent" + + +def test_presence_implies_value_message_from_description(): + """Rule description is emitted as sh:message on the SPARQLConstraint.""" + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + sparql_nodes = list(g.objects(EX_PIV.Document, SH.sparql)) + messages = [str(m) for node in sparql_nodes for m in g.objects(node, SH.message)] + + assert any("status must be Published" in m for m in messages), f"Expected message about Published, got: {messages}" + + +def test_presence_implies_value_sparql_syntax_valid(): + """Generated SPARQL for presence-implies-value rules must be syntactically valid.""" + from rdflib.plugins.sparql import prepareQuery + + g = _parse_shacl(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML) + + for shape in (EX_PIV.Document, EX_PIV.Device): + for query in _sparql_queries(g, shape): + prepareQuery(query) + + +@pytest.mark.parametrize( + "instance,violates", + [ + pytest.param('ex:x a ex:Document ; ex:signature "s" ; ex:status ex:Published .', False, id="single-ok"), + pytest.param( + 'ex:x a ex:Document ; ex:review_score "4.5"^^xsd:float ; ex:status ex:Reviewed .', False, id="set-ok" + ), + pytest.param( + 'ex:x a ex:Document ; ex:review_score "3.0"^^xsd:float ; ex:status ex:Approved .', + False, + id="set-other-member-ok", + ), + pytest.param("ex:x a ex:Document ; ex:status ex:Draft .", False, id="unguarded"), + pytest.param('ex:x a ex:Document ; ex:signature "s" ; ex:status ex:Draft .', True, id="wrong-value"), + pytest.param( + 'ex:x a ex:Document ; ex:review_score "4.5"^^xsd:float ; ex:status ex:Published .', + True, + id="not-in-set", + ), + pytest.param('ex:x a ex:Document ; ex:signature "s" .', True, id="target-absent"), + pytest.param('ex:x a ex:Device ; ex:manual_value 1.5 ; ex:mode "Manual" .', False, id="literal-ok"), + pytest.param('ex:x a ex:Device ; ex:manual_value 1.5 ; ex:mode "Auto" .', True, id="literal-wrong"), + ], +) +def test_presence_implies_value_pyshacl_end_to_end(instance: str, violates: bool): + """End-to-end: pyshacl passes conforming instances and flags violations.""" + data = ( + "@prefix ex: .\n" + "@prefix xsd: .\n" + instance + ) + conforms, focus_nodes = _validate_rules(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML, data) + assert focus_nodes == ({EX_PIV.x} if violates else set()) + assert conforms is not violates + + +@pytest.mark.parametrize( + "instance,value", + [ + pytest.param('ex:signature "s" ; ex:status ex:Draft', EX_PIV.Draft, id="wrong-value"), + pytest.param('ex:signature "s"', EX_PIV.x, id="target-absent"), + ], +) +def test_presence_implies_value_result_names_path_and_value(instance, value): + """A result names the target property (``sh:resultPath``) and the + offending value (``sh:value``); when the target is absent, SHACL §5.3.2 + falls back to the focus node as the value.""" + data = f"@prefix ex: .\nex:x a ex:Document ; {instance} ." + _, results = _rule_results(_PRESENCE_IMPLIES_VALUE_SCHEMA_YAML, data) + assert results == [(EX_PIV.x, EX_PIV.status, value)] + + +# =========================================================================== +# equals_string / equals_string_in compare strings +# =========================================================================== +# +# The metamodel defines both operators for slots of range string: "the slot +# must have range string and the value of the slot must equal the specified +# value". Enums, whose values are strings in instance data, and types with +# datatype xsd:string are treated alike. A rule applying them to a slot of +# any other range is skipped with a warning: its RDF values are typed literals +# ("3"^^xsd:integer, false) or IRIs that equal no string, so a translated +# constraint would report conforming data or never fire. On a string-valued +# slot the value is compared with the SPARQL "=" operator, which matches the +# RDF 1.1-identical plain and xsd:string literal forms alike, and an +# incomparable value counts as unequal rather than as an error. +# +# References: +# - SPARQL 1.1 §17.4.1.3 +# - SPARQL 1.1 §17.4.1.9 +# - RDF 1.1 Concepts §3.3 +# =========================================================================== + +_RULE_TARGET_RANGE_SCHEMA_YAML = """ +id: https://example.org/rule-target-range +name: rule_target_range +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/rule-target-range/ + xsd: http://www.w3.org/2001/XMLSchema# +imports: + - linkml:types +default_prefix: ex +default_range: string +types: + label_string: + typeof: string + token_string: + typeof: string + uri: xsd:token + flag_boolean: + typeof: boolean +enums: + Code: + permissible_values: + alpha: + beta: + Color: + permissible_values: + Red: + meaning: ex:Red + Blue: + meaning: ex:Blue + Answer: + permissible_values: + "true": + meaning: ex:Yes + "false": + meaning: ex:No + Mixed: + permissible_values: + A: + meaning: ex:A + B: +classes: + Item: + class_uri: ex:Item + attributes: + id: + identifier: true + range: uriorcurie + Thing: + class_uri: ex:Thing + attributes: + guard: + multivalued: {guard_multivalued} + target: + range: {range} + multivalued: {multivalued} + rules: + - open_world: {open_world} + preconditions: + slot_conditions: + guard: {guard_condition} + postconditions: + slot_conditions: + target: {target_condition} + Sub: + is_a: Thing + class_uri: ex:Sub +""" + +EX_RTR = rdflib.Namespace("https://example.org/rule-target-range/") + +_RTR_PREFIXES = ( + "@prefix ex: .\n@prefix xsd: .\n" +) + + +def _rule_target_schema( + target_range: str, + value: str | list[str], + multivalued: bool = False, + *, + operator: str = "equals_string", + open_world: bool = False, + guard_multivalued: bool = False, + guard_extra: dict | None = None, + target_extra: dict | None = None, +) -> str: + """The rule-target schema: if ``guard`` is present, ``target`` (of *target_range*) must satisfy *operator*. + + *value* is the operand of *operator* (a list for ``equals_string_in``); + *guard_extra* and *target_extra* add operators to the two conditions. + ``Sub`` inherits the rule from ``Thing``. + """ + return _RULE_TARGET_RANGE_SCHEMA_YAML.format( + range=target_range, + multivalued=str(multivalued).lower(), + guard_multivalued=str(guard_multivalued).lower(), + open_world=str(open_world).lower(), + guard_condition=json.dumps({"value_presence": "PRESENT", **(guard_extra or {})}), + target_condition=json.dumps({operator: value, **(target_extra or {})}), + ) + + +@pytest.mark.parametrize( + "target_range,operator,value,conforming_term", + [ + pytest.param("integer", "equals_string", "3", "3", id="integer"), + pytest.param("integer", "equals_string_in", ["3", "4"], "3", id="integer-equals-string-in"), + pytest.param("float", "equals_string", "1.5", '"1.5"^^xsd:float', id="float"), + pytest.param("decimal", "equals_string", "1.5", "1.5", id="decimal"), + pytest.param("date", "equals_string", "2024-01-01", '"2024-01-01"^^xsd:date', id="date"), + pytest.param("uriorcurie", "equals_string", "ex:a", "ex:a", id="uriorcurie"), + pytest.param("token_string", "equals_string", "a", '"a"^^xsd:token', id="string-derived-xsd-token"), + pytest.param("Item", "equals_string", "ex:i1", "ex:i1 . ex:i1 a ex:Item", id="class"), + ], +) +def test_rule_equals_string_on_non_string_range_skipped(caplog, target_range, operator, value, conforming_term): + """equals_string on a slot that does not hold strings is skipped with a warning. + + The data holds a typed literal or an IRI, which equals no string: comparing + it with one would report every conforming instance. The conforming instance + must therefore pass, and the skip must be reported. + """ + schema = _rule_target_schema(target_range, value, operator=operator) + with caplog.at_level(logging.WARNING): + g = _parse_shacl(schema) + + assert _sparql_queries(g, EX_RTR.Thing) == [] + assert _sparql_queries(g, EX_RTR.Sub) == [] + assert any( + f"slot 'target', whose range '{target_range}' is neither an enum nor a type with datatype xsd:string or " + "xsd:boolean" in rec.message + for rec in caplog.records + ), caplog.text + conforms, focus_nodes = _validate_rules( + schema, f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g" ; ex:target {conforming_term} .' + ) + assert conforms and focus_nodes == set() + + +_STRING_TARGETS = [ + pytest.param("string", "x", '"x"', '"y"', id="string"), + pytest.param("label_string", "x", '"x"', '"y"', id="string-derived"), + pytest.param("curie", "ex:a", '"ex:a"', '"ex:b"', id="curie"), + pytest.param("Code", "alpha", '"alpha"', '"beta"', id="enum-literal"), + pytest.param("Color", "Red", "ex:Red", "ex:Blue", id="enum-meaning"), + pytest.param("Answer", "true", "ex:Yes", "ex:No", id="enum-meaning-named-true"), +] + + +@pytest.mark.parametrize("target_range,value,allowed,other", _STRING_TARGETS) +@pytest.mark.parametrize( + "body,violates", + [ + pytest.param('ex:guard "g" ; ex:target {allowed}', False, id="allowed"), + pytest.param('ex:guard "g" ; ex:target {allowed_xsd_string}', False, id="allowed-xsd-string"), + pytest.param('ex:guard "g" ; ex:target {other}', True, id="other"), + pytest.param('ex:guard "g" ; ex:target {allowed}, {other}', True, id="allowed-and-other"), + pytest.param('ex:guard "g"', True, id="target-absent"), + pytest.param("ex:target {other}", False, id="guard-absent"), + ], +) +@pytest.mark.parametrize("multivalued", [False, True]) +def test_rule_equals_string_on_string_range_compares_values( + target_range, value, allowed, other, body, violates, multivalued +): + """On a string-valued target, equals_string compares the value the range holds. + + A string type holds plain literals, an enum its meaning IRI or a plain + literal; the ``xsd:string``-typed form of a plain literal is the same RDF + 1.1 term and must also satisfy the rule. An enum value named ``true`` + with a meaning is an IRI like any other, not a boolean. + """ + allowed_xsd_string = f"{allowed}^^xsd:string" if allowed.startswith('"') else allowed + schema = _rule_target_schema(target_range, value, multivalued) + data = body.format(allowed=allowed, allowed_xsd_string=allowed_xsd_string, other=other) + + assert len(_sparql_queries(_parse_shacl(schema), EX_RTR.Thing)) == 1 + _, focus_nodes = _validate_rules(schema, f"{_RTR_PREFIXES}ex:x a ex:Thing ; {data} .") + assert focus_nodes == ({EX_RTR.x} if violates else set()) + + +_RULE_INSTANCES = [ + pytest.param({"guard": "g", "target": "{value}"}, True, True, id="allowed"), + pytest.param({"guard": "g", "target": "{other}"}, False, False, id="other"), + pytest.param({"guard": "g"}, False, True, id="target-absent"), + pytest.param({"target": "{other}"}, True, True, id="guard-absent"), +] +"""JSON instances of the rule-target schema, with their validity under closed- and open-world rules.""" + + +def _json_schema_and_shacl_verdicts( + schema: str, target_class: str, obj: dict, coerce_xsd_string: bool = False +) -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL rules generated for *schema* accept *obj*. + + *obj* is validated as is by the generated JSON Schema, and as RDF loaded + through the generated JSON-LD context by the generated SHACL shapes (rule + constraints only). With *coerce_xsd_string* the context types the + ``target`` slot ``xsd:string``, as JSON-LD 1.1 type coercion permits, + which yields the RDF 1.1-identical typed form of a plain literal. + """ + import jsonschema + + from linkml.generators.jsonldcontextgen import ContextGenerator + from linkml.generators.jsonschemagen import JsonSchemaGenerator + + json_schema = json.loads(JsonSchemaGenerator(schema, top_class=target_class).serialize()) + json_schema_valid = jsonschema.Draft7Validator(json_schema).is_valid(obj) + + context = json.loads(ContextGenerator(schema).serialize())["@context"] + if coerce_xsd_string and context["target"].get("@type") is None: + context["target"]["@type"] = "xsd:string" + document = {"@context": context, "@type": target_class, **obj} + _, focus_nodes = _validate_rules(schema, rdflib.Graph().parse(data=json.dumps(document), format="json-ld")) + return json_schema_valid, focus_nodes == set() + + +@pytest.mark.parametrize( + "target_range,value,other", + [ + pytest.param("string", "x", "y", id="string"), + pytest.param("curie", "ex:a", "ex:b", id="curie"), + pytest.param("Code", "alpha", "beta", id="enum-literal"), + pytest.param("Color", "Red", "Blue", id="enum-meaning"), + ], +) +@pytest.mark.parametrize("instance,valid,_valid_open_world", _RULE_INSTANCES) +@pytest.mark.parametrize("coerce_xsd_string", [False, True]) +def test_rule_equals_string_agrees_with_json_schema( + target_range, value, other, instance, valid, _valid_open_world, coerce_xsd_string +): + """The SHACL rule and the JSON Schema rule decide the same instance alike. + + One JSON object is checked by both generated artifacts (see + :func:`_json_schema_and_shacl_verdicts`), with and without ``xsd:string`` + coercion of the target, which the test adds to the generated context. An + enum whose values all have a meaning in one namespace is mapped to those + IRIs by the context and stays untyped. + """ + obj = {key: text.format(value=value, other=other) for key, text in instance.items()} + verdicts = _json_schema_and_shacl_verdicts( + _rule_target_schema(target_range, value), "Thing", obj, coerce_xsd_string + ) + assert verdicts == (valid, valid) + + +@pytest.mark.parametrize( + "target_range,value,other", + [ + pytest.param("string", "x", "y", id="string"), + pytest.param("Color", "Red", "Blue", id="enum-meaning"), + ], +) +@pytest.mark.parametrize("instance,valid_closed_world,valid_open_world", _RULE_INSTANCES) +@pytest.mark.parametrize("target_class", ["Thing", "Sub"]) +@pytest.mark.parametrize("open_world", [False, True]) +def test_rule_inheritance_and_open_world_agree_with_json_schema( + target_range, value, other, instance, valid_closed_world, valid_open_world, target_class, open_world +): + """A rule binds the instances of subclasses of its class, and ``open_world`` + lets the postcondition be omitted, in SHACL as in JSON Schema. + + The metamodel defines ``open_world`` as "the postconditions may be omitted + in instance data", so an absent target satisfies an open-world rule. + """ + obj = {key: text.format(value=value, other=other) for key, text in instance.items()} + schema = _rule_target_schema(target_range, value, open_world=open_world) + valid = valid_open_world if open_world else valid_closed_world + assert _json_schema_and_shacl_verdicts(schema, target_class, obj) == (valid, valid) + + +@pytest.mark.parametrize("flag_range", ["boolean", "flag_boolean"]) +@pytest.mark.parametrize("open_world", [False, True]) +@pytest.mark.parametrize("required", ["true", "1", "false", "0"]) +@pytest.mark.parametrize( + "flag,datatype", + [ + pytest.param("true", XSD.boolean, id="true"), + pytest.param("1", XSD.boolean, id="lexical-1"), + pytest.param("false", XSD.boolean, id="false"), + pytest.param("0", XSD.boolean, id="lexical-0"), + pytest.param("true", None, id="string-true"), + pytest.param(None, None, id="absent"), + ], +) +def test_rule_boolean_target_compared_by_value(flag_range, open_world, required, flag, datatype): + """equals_string on an xsd:boolean-typed slot (the boolean guard, for + "true") requires the boolean its value denotes in the lexical space of + xsd:boolean (XML Schema 1.1 Part 2 §3.3.2.2), whichever type the slot's + range derives it from. The flag is compared by value, so every lexical + form of a boolean satisfies the rule and a string "true" does not; an + open-world rule lets the flag be omitted. + + The data is built with unnormalised literals: rdflib would otherwise + rewrite ``"1"^^xsd:boolean`` to ``true`` while parsing, and a lexical + comparison would pass as well. + """ + schema = _rule_target_schema(flag_range, required, open_world=open_world) + required_value = required in ("true", "1") + queries = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + assert len(queries) == 1 + assert f"?value = {str(required_value).lower()}" in queries[0], queries[0] + + data = rdflib.Graph() + data.add((EX_RTR.x, RDF.type, EX_RTR.Thing)) + data.add((EX_RTR.x, EX_RTR.guard, Literal("g"))) + if flag is not None: + data.add((EX_RTR.x, EX_RTR.target, Literal(flag, datatype=datatype, normalize=False))) + if flag is None: + violates = not open_world + else: + violates = datatype is None or (flag in ("true", "1")) != required_value + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_RTR.x} if violates else set()) + + +@pytest.mark.parametrize("value", ["yes", "True", "TRUE", " true", ""]) +def test_rule_boolean_target_non_lexical_value_skipped(caplog, value): + """A value outside the lexical space of xsd:boolean on a boolean slot equals + no boolean, so the rule is skipped with a warning. The lexical space is + exactly {true, false, 1, 0}: it is case-sensitive and has no whitespace.""" + with caplog.at_level(logging.WARNING): + g = _parse_shacl(_rule_target_schema("boolean", value)) + assert _sparql_queries(g, EX_RTR.Thing) == [] + assert any("not all xsd:boolean lexical forms" in rec.message for rec in caplog.records), caplog.text + + +@pytest.mark.parametrize( + "target,violates", + [ + pytest.param("ex:A", False, id="meaning-iri"), + pytest.param('"B"', False, id="literal"), + pytest.param('"B"^^xsd:string', False, id="literal-xsd-string"), + pytest.param('"A"', True, id="literal-of-value-with-meaning"), + pytest.param("ex:B", True, id="iri-of-value-without-meaning"), + pytest.param('"C"', True, id="other"), + ], +) +def test_rule_equals_string_in_mixes_meaning_iris_and_literals(target, violates): + """``equals_string_in`` over permissible values with and without a meaning + compares each as the term it is rendered as: its meaning IRI, or a literal.""" + schema = _rule_target_schema("Mixed", ["A", "B"], operator="equals_string_in") + _, focus_nodes = _validate_rules(schema, f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g" ; ex:target {target} .') + assert focus_nodes == ({EX_RTR.x} if violates else set()) + + +@pytest.mark.parametrize( + "target,violates", + [pytest.param('"x"', False, id="allowed"), pytest.param('"y"', True, id="other")], +) +def test_rule_multivalued_guard(target, violates): + """Several guard values trigger the rule once per focus node, like one.""" + schema = _rule_target_schema("string", "x", guard_multivalued=True) + data = f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g1", "g2", "g3" ; ex:target {target} .' + _, results = _rule_results(schema, data) + assert results == ([(EX_RTR.x, EX_RTR.target, rdflib.Literal("y"))] if violates else []) + + +def test_rule_query_yields_one_solution_per_focus_node(): + """Each solution of a SPARQL-based constraint is a validation result + (SHACL §5.3.2), so several guard values must not multiply the solutions. + pyshacl merges identical results; the query itself must not rely on it.""" + schema = _rule_target_schema("string", "x", guard_multivalued=True) + (query,) = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + data = rdflib.Graph().parse( + data=f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g1", "g2", "g3" ; ex:target "y" .', format="turtle" + ) + assert len(list(data.query(query, initBindings={"this": EX_RTR.x}))) == 1 + + +@pytest.mark.parametrize("dawg_literal_collation", [False, True]) +def test_rule_incomparable_value_is_a_violation(monkeypatch, dawg_literal_collation): + """A value no allowed value can be compared with equals none of them. + + SPARQL ``=`` raises a type error for literals it cannot compare + (RDFterm-equal, SPARQL 1.1 §17.4.1.7), and a FILTER error drops the row, so + a spec-conformant engine would miss the violation unless the comparison + turns the error into false. ``rdflib.DAWG_LITERAL_COLLATION`` switches + rdflib from its lenient comparison to that spec-conformant one. + """ + monkeypatch.setattr(rdflib, "DAWG_LITERAL_COLLATION", dawg_literal_collation) + schema = _rule_target_schema("string", "x") + data = f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g" ; ex:target "x"^^ex:custom .' + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == {EX_RTR.x} + + exclusive = _exclusive_schema("Tag", "alpha", 1) + data = ( + '@prefix ex: .\nex:x a ex:Thing ; ex:tags "alpha", "beta"^^ex:custom .' + ) + _, focus_nodes = _validate_rules(exclusive, data) + assert focus_nodes == {EX_EXR.x} + + +def test_rule_value_not_a_permissible_value_warns(caplog): + """An equals_string value that is not a permissible value of the enum is + reported: no valid value of the slot can equal it, so the rule rejects + every instance it applies to.""" + schema = _rule_target_schema("Color", "Purple") + with caplog.at_level(logging.WARNING): + queries = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + + assert len(queries) == 1 + assert any("'Purple', which is not a permissible value of enum 'Color'" in rec.message for rec in caplog.records) + _, focus_nodes = _validate_rules(schema, f'{_RTR_PREFIXES}ex:x a ex:Thing ; ex:guard "g" ; ex:target ex:Red .') + assert focus_nodes == {EX_RTR.x} + + +@pytest.mark.parametrize( + "value", + [ + pytest.param('a"b', id="quote"), + pytest.param("a\\b", id="backslash"), + pytest.param("line\nbreak", id="newline"), + pytest.param("carriage\rreturn", id="carriage-return"), + pytest.param("tab\there", id="tab"), + pytest.param("a\\u0022b", id="backslash-u"), + pytest.param("a\\U00000022b", id="backslash-capital-u"), + pytest.param("check ✓", id="non-ascii"), + pytest.param("", id="empty"), + ], +) +def test_rule_string_value_round_trips(value): + """An equals_string value of any content is matched exactly: the query + parses, the value itself satisfies the rule, and any other value does not. + + Codepoint escapes are replaced before a SPARQL query is parsed (SPARQL 1.1 + §19.2), so a backslash followed by ``u`` in the value must not reach the + query as an escape sequence. + """ + from rdflib.plugins.sparql import prepareQuery + + schema = _rule_target_schema("string", value) + queries = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + assert len(queries) == 1 + prepareQuery(queries[0]) + + for target, violates in ((value, False), (value + "!", True)): + data = rdflib.Graph() + data.add((EX_RTR.x, RDF.type, EX_RTR.Thing)) + data.add((EX_RTR.x, EX_RTR.guard, Literal("g"))) + data.add((EX_RTR.x, EX_RTR.target, Literal(target))) + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_RTR.x} if violates else set()), repr(target) + + +_EXCLUSIVE_RANGE_SCHEMA_YAML = """ +id: https://example.org/exclusive-range +name: exclusive_range +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/exclusive-range/ +imports: + - linkml:types +default_prefix: ex +default_range: string +enums: + Tag: + permissible_values: + alpha: + beta: + gamma: +classes: + Thing: + class_uri: ex:Thing + attributes: + tags: + range: {range} + multivalued: true + rules: + - preconditions: + slot_conditions: + tags: {precondition} + postconditions: + slot_conditions: + tags: + maximum_cardinality: {max_card} +""" + +EX_EXR = rdflib.Namespace("https://example.org/exclusive-range/") + + +def _exclusive_schema(tag_range: str, value: str, max_card: int, *, has_member: bool = True) -> str: + """The exclusive-value schema: if *value* is one of the ``tags``, there are at most *max_card* of them. + + The precondition is ``has_member: {equals_string: value}``, or with + ``has_member=False`` the bare ``equals_string: value``. + """ + precondition = {"has_member": {"equals_string": value}} if has_member else {"equals_string": value} + return _EXCLUSIVE_RANGE_SCHEMA_YAML.format( + range=tag_range, precondition=json.dumps(precondition), max_card=max_card + ) + + +@pytest.mark.parametrize("has_member", [True, False]) +@pytest.mark.parametrize( + "max_card,values,violates", + [ + pytest.param(1, '"alpha", "beta"', True, id="max1-plain"), + pytest.param(1, '"alpha"^^xsd:string, "beta"^^xsd:string', True, id="max1-xsd-string"), + pytest.param(1, '"alpha"', False, id="max1-alone"), + pytest.param(1, '"beta", "gamma"', False, id="max1-absent"), + pytest.param(2, '"alpha", "beta", "gamma"', True, id="max2-plain"), + pytest.param(2, '"alpha"^^xsd:string, "beta", "gamma"', True, id="max2-xsd-string"), + pytest.param(2, '"alpha", "beta"', False, id="max2-within"), + ], +) +def test_exclusive_value_matches_value_not_term(has_member, max_card, values, violates): + """The exclusive value is matched by value, so its RDF 1.1-identical + ``xsd:string``-typed form triggers the rule as the plain literal does, and + a ``maximum_cardinality`` above 1 is enforced. The precondition + ``has_member: {equals_string: V}`` and the bare ``equals_string: V`` are + translated alike.""" + schema = _exclusive_schema("Tag", "alpha", max_card, has_member=has_member) + data = ( + "@prefix ex: .\n" + "@prefix xsd: .\n" + f"ex:x a ex:Thing ; ex:tags {values} ." + ) + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_EXR.x} if violates else set()) + + +@pytest.mark.parametrize("max_card", [1, 2]) +def test_exclusive_value_result_names_path_and_value(max_card): + """A result names the slot. For ``maximum_cardinality: 1`` its value is + each value coexisting with the exclusive one; otherwise no value is + projected and SHACL §5.3.2 falls back to the focus node.""" + data = '@prefix ex: .\nex:x a ex:Thing ; ex:tags "alpha", "beta", "gamma" .' + _, results = _rule_results(_exclusive_schema("Tag", "alpha", max_card), data) + if max_card == 1: + assert sorted(results) == sorted( + [(EX_EXR.x, EX_EXR.tags, Literal("beta")), (EX_EXR.x, EX_EXR.tags, Literal("gamma"))] + ) + else: + assert results == [(EX_EXR.x, EX_EXR.tags, EX_EXR.x)] + + +@pytest.mark.parametrize("max_card", [1, 2]) +def test_exclusive_value_query_yields_distinct_solutions(max_card): + """The exclusive value in both RDF 1.1-identical forms binds twice; each + solution is a validation result (SHACL §5.3.2), so the query must not + multiply them.""" + (query,) = _sparql_queries(_parse_shacl(_exclusive_schema("Tag", "alpha", max_card)), EX_EXR.Thing) + data = rdflib.Graph().parse( + data="@prefix ex: .\n" + "@prefix xsd: .\n" + 'ex:x a ex:Thing ; ex:tags "alpha", "alpha"^^xsd:string, "beta" .', + format="turtle", + ) + assert len(list(data.query(query, initBindings={"this": EX_EXR.x}))) == 1 + + +def test_exclusive_value_bare_equals_string_warns(caplog): + """A bare equals_string precondition on a multivalued slot is translated as + "one of the values equals", with a warning: the specification applies a + slot constraint to all members of a collection, and has_member states the + intended reading explicitly.""" + with caplog.at_level(logging.WARNING): + assert _sparql_queries(_parse_shacl(_exclusive_schema("Tag", "alpha", 1, has_member=False)), EX_EXR.Thing) + assert any("has_member" in rec.message and "all members" in rec.message for rec in caplog.records), caplog.text + caplog.clear() + with caplog.at_level(logging.WARNING): + _parse_shacl(_exclusive_schema("Tag", "alpha", 1)) + assert not any("has_member" in rec.message for rec in caplog.records), caplog.text + + +@pytest.mark.parametrize( + "precondition", + [ + pytest.param({"has_member": {"equals_string": "alpha", "pattern": "^a"}}, id="inside-has_member"), + pytest.param({"has_member": {"equals_string": "alpha"}, "minimum_cardinality": 2}, id="beside-has_member"), + ], +) +def test_exclusive_value_has_member_extra_operator_skipped(caplog, precondition): + """has_member is exact too: an operator next to its equals_string, or next + to has_member itself, makes the rule untranslatable.""" + precondition = json.dumps(precondition) + schema = _EXCLUSIVE_RANGE_SCHEMA_YAML.format(range="Tag", precondition=precondition, max_card=1) + with caplog.at_level(logging.WARNING): + assert _sparql_queries(_parse_shacl(schema), EX_EXR.Thing) == [] + assert any("match none of the translated patterns" in rec.message for rec in caplog.records), caplog.text + + +def test_exclusive_value_on_non_string_range_skipped(caplog): + """equals_string on a slot that does not hold strings is skipped with a + warning in the exclusive-value pattern too: the integer ``3`` never equals + the string "3", so the constraint would never fire.""" + schema = _exclusive_schema("integer", "3", 1) + with caplog.at_level(logging.WARNING): + g = _parse_shacl(schema) + + assert _sparql_queries(g, EX_EXR.Thing) == [] + assert any("whose range 'integer' is neither an enum" in rec.message for rec in caplog.records), caplog.text + + +def test_rule_non_operator_fields_are_the_metamodel_element_metadata(): + """The fields the operator accounting ignores are exactly the metamodel's + ``element`` slots that are not ``slot_expression`` slots.""" + from linkml_runtime.utils.formatutils import underscore + from linkml_runtime.utils.introspection import package_schemaview + + metamodel = package_schemaview("linkml_runtime.linkml_model.meta") + element_slots = {underscore(s) for s in metamodel.class_slots("element")} + operator_slots = {underscore(s) for s in metamodel.class_slots("slot_expression")} + + assert ShaclGenerator._NON_OPERATOR_FIELDS == element_slots - operator_slots + assert {"description", "annotations", "title"} <= ShaclGenerator._NON_OPERATOR_FIELDS + + +def test_rule_condition_metadata_does_not_block_translation(): + """Metadata on a condition does not change which instances satisfy it, + so it does not make an otherwise recognised rule untranslatable.""" + schema = _rule_target_schema( + "string", + "x", + guard_extra={"title": "Guard set"}, + target_extra={"description": "The target must then be x.", "comments": ["Metadata only."]}, + ) + assert len(_sparql_queries(_parse_shacl(schema), EX_RTR.Thing)) == 1 + + +# =========================================================================== +# Rule inheritance +# +# A rule applies to "all members of this class" (metamodel `rules`), so a +# class shape carries the rules of its ancestors and mixins, each translated +# in the class's own context. +# =========================================================================== + +_RULE_INHERITANCE_SCHEMA_YAML = """ +id: https://example.org/rule-inheritance +name: rule_inheritance +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/rule-inheritance/ +imports: + - linkml:types +default_prefix: ex +default_range: string +enums: + Color: + permissible_values: + Red: + meaning: ex:Red + Blue: + meaning: ex:Blue + Shade: + permissible_values: + Red: + meaning: ex:ShadeRed +slots: + guard: {} + target: + range: Color + code: {} + label: {} +classes: + Base: + class_uri: ex:Base + slots: [guard, target] + rules: + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + target: + equals_string: Red + Child: + is_a: Base + class_uri: ex:Child + GrandChild: + is_a: Child + class_uri: ex:GrandChild + Shaded: + is_a: Base + class_uri: ex:Shaded + slot_usage: + target: + range: Shade + Counted: + is_a: Base + class_uri: ex:Counted + slot_usage: + target: + range: integer + Labelled: + mixin: true + class_uri: ex:Labelled + slots: [code, label] + rules: + - preconditions: + slot_conditions: + code: + value_presence: PRESENT + postconditions: + slot_conditions: + label: + equals_string: x + Mixed: + class_uri: ex:Mixed + mixins: [Labelled] + SameUri: + is_a: Base + class_uri: ex:Base +""" + +EX_RI = rdflib.Namespace("https://example.org/rule-inheritance/") + + +def test_rule_inherited_by_descendant_shapes(caplog): + """The shape of a class carries the rules of its ancestors and mixins, each + translated in the class's own context: a ``slot_usage`` that narrows the + target to another enum resolves the value there, and one that narrows it to + a non-string range skips the rule for that class only. A class sharing its + ancestor's ``class_uri`` shares its shape, which carries the rule once.""" + with caplog.at_level(logging.WARNING): + g = _parse_shacl(_RULE_INHERITANCE_SCHEMA_YAML) + + for cls in ("Base", "Child", "GrandChild", "Shaded", "Labelled", "Mixed"): + assert len(_sparql_queries(g, EX_RI[cls])) == 1, cls + assert f"<{EX_RI.ShadeRed}>" in _sparql_queries(g, EX_RI.Shaded)[0] + assert _sparql_queries(g, EX_RI.Counted) == [] + assert any("'Counted'" in rec.message and "is neither an enum" in rec.message for rec in caplog.records) + + +@pytest.mark.parametrize( + "instance,violates", + [ + pytest.param('ex:x a ex:Child ; ex:guard "g" ; ex:target ex:Red .', False, id="child-ok"), + pytest.param('ex:x a ex:Child ; ex:guard "g" ; ex:target ex:Blue .', True, id="child-violates"), + pytest.param('ex:x a ex:GrandChild ; ex:guard "g" ; ex:target ex:Blue .', True, id="grandchild-violates"), + pytest.param('ex:x a ex:Shaded ; ex:guard "g" ; ex:target ex:ShadeRed .', False, id="narrowed-ok"), + pytest.param('ex:x a ex:Shaded ; ex:guard "g" ; ex:target ex:Red .', True, id="narrowed-violates"), + pytest.param('ex:x a ex:Mixed ; ex:code "c" ; ex:label "x" .', False, id="mixin-ok"), + pytest.param('ex:x a ex:Mixed ; ex:code "c" ; ex:label "y" .', True, id="mixin-violates"), + ], +) +def test_rule_inherited_rule_enforced_on_descendant_instances(instance, violates): + """An instance typed only with a descendant class is held to the inherited rule.""" + data = f"@prefix ex: .\n{instance}" + _, focus_nodes = _validate_rules(_RULE_INHERITANCE_SCHEMA_YAML, data) + assert focus_nodes == ({EX_RI.x} if violates else set()) + + +def test_rule_shared_shape_reports_once(): + """Two classes sharing a ``class_uri`` share one shape, so an instance + violating the rule both inherit gets one result, not one per class.""" + data = '@prefix ex: .\nex:x a ex:Base ; ex:guard "g" ; ex:target ex:Blue .' + _, results = _rule_results(_RULE_INHERITANCE_SCHEMA_YAML, data) + assert results == [(EX_RI.x, EX_RI.target, EX_RI.Blue)] + + +# =========================================================================== +# Slot resolution and value rendering +# +# A rule's SPARQL body must query the same IRI that ``sh:path`` emits for the +# slot, and resolve the slot's range in the class: +# 1. a slot_usage `slot_uri` (or enum `range`) override applies to the rule; +# 2. an alias-form key (``my_slot`` for slot ``my slot``) names the slot; +# 3. a slot without a range takes the schema's `default_range`; +# 4. built-in type names resolve without importing linkml:types; +# 5. a condition on an unknown or identifier slot makes the rule untranslatable. +# =========================================================================== + + +_ENUM_NARROWING_SCHEMA_YAML = """ +id: https://example.org/enum-narrowing +name: enum_narrowing_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/enum-narrowing/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +enums: + BaseMode: + permissible_values: + Active: + meaning: ex:GLOBAL_Active + SceneMode: + permissible_values: + Active: + meaning: ex:LOCAL_Active + +slots: + activator: + range: string + slot_uri: ex:activator + mode: + range: BaseMode + slot_uri: ex:mode + +classes: + Scene: + class_uri: ex:Scene + slots: + - activator + - mode + slot_usage: + mode: + range: SceneMode + rules: + - description: If an activator is present the mode must be Active. + preconditions: + slot_conditions: + activator: + value_presence: PRESENT + postconditions: + slot_conditions: + mode: + equals_string: Active +""" + +EX_EN = rdflib.Namespace("https://example.org/enum-narrowing/") + + +def test_rule_enum_range_narrowed_by_slot_usage(): + """A slot_usage range override to a class-specific enum must resolve the + value's meaning against the induced (narrowed) enum, not the base range.""" + queries = _sparql_queries(_parse_shacl(_ENUM_NARROWING_SCHEMA_YAML), EX_EN.Scene) + assert len(queries) == 1 + query = queries[0] + assert str(EX_EN.LOCAL_Active) in query, f"must resolve the narrowed enum meaning, got:\n{query}" + assert "GLOBAL_Active" not in query, f"must not resolve the base enum meaning, got:\n{query}" + + +_SLOT_RESOLUTION_SCHEMA_YAML = """ +id: https://example.org/slot-resolution +name: slot_resolution +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/slot-resolution/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + guard: {} + my slot: {} + target: {} +classes: + Thing: + class_uri: ex:Thing + slots: [guard, my slot, target] + slot_usage: + target: + slot_uri: ex:narrowed_target + rules: + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + target: + equals_string: x + - preconditions: + slot_conditions: + my_slot: + value_presence: PRESENT + postconditions: + slot_conditions: + target: + equals_string: x +""" + +EX_SR = rdflib.Namespace("https://example.org/slot-resolution/") + + +def test_rule_slot_iris_match_sh_path(): + """Every predicate a rule queries is one that ``sh:path`` emits: a + slot_usage ``slot_uri`` override and an alias-form key resolve to the + slot's own IRI rather than a fabricated one the data never uses.""" + g = _parse_shacl(_SLOT_RESOLUTION_SCHEMA_YAML) + + paths = {str(path) for path in g.objects(None, SH.path)} + queries = _sparql_queries(g, EX_SR.Thing) + assert len(queries) == 2 + for query in queries: + assert set(re.findall(r"<([^>]+)>", query)) <= paths, query + assert f"<{EX_SR.narrowed_target}>" in query, query + assert any(f"<{EX_SR.my_slot}>" in query for query in queries) + + _, focus_nodes = _validate_rules( + _SLOT_RESOLUTION_SCHEMA_YAML, + "@prefix ex: .\n" + 'ex:x a ex:Thing ; ex:my_slot "s" ; ex:narrowed_target "y" .', + ) + assert focus_nodes == {EX_SR.x} + + +_RANGE_RESOLUTION_SCHEMA_YAML = """ +id: https://example.org/range-resolution +name: range_resolution +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/range-resolution/ +imports: + - linkml:types +default_prefix: ex +default_range: {default_range} +slots: + guard: + range: string + target: {target_slot} +classes: + Thing: + class_uri: ex:Thing + slots: {class_slots} + slot_usage: {slot_usage} + rules: + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + target: + equals_string: "3" +""" + +EX_RR = rdflib.Namespace("https://example.org/range-resolution/") + + +@pytest.mark.parametrize( + "default_range,target_slot,class_slots,slot_usage,translated", + [ + pytest.param("string", "{}", "[guard, target]", "{}", True, id="default-range-string"), + pytest.param("integer", "{}", "[guard, target]", "{}", False, id="default-range-integer"), + pytest.param( + "string", + "{range: integer}", + "[guard, target]", + "{target: {range: string}}", + True, + id="slot-usage-to-string", + ), + pytest.param( + "string", "{}", "[guard, target]", "{target: {range: integer}}", False, id="slot-usage-to-integer" + ), + pytest.param("string", "{}", "[guard]", "{}", True, id="slot-not-on-class-default-string"), + pytest.param("integer", "{}", "[guard]", "{}", False, id="slot-not-on-class-default-integer"), + ], +) +def test_rule_target_range_resolved_in_class_context(default_range, target_slot, class_slots, slot_usage, translated): + """Whether the target holds strings is decided by its range in the class: + the schema's ``default_range`` for a slot without one, as refined by + ``slot_usage``, also for a slot the class does not declare.""" + schema = _RANGE_RESOLUTION_SCHEMA_YAML.format( + default_range=default_range, target_slot=target_slot, class_slots=class_slots, slot_usage=slot_usage + ) + assert len(_sparql_queries(_parse_shacl(schema), EX_RR.Thing)) == (1 if translated else 0) + if translated: + prefix = "@prefix ex: .\n" + assert _validate_rules(schema, f'{prefix}ex:x a ex:Thing ; ex:guard "g" ; ex:target "3" .')[1] == set() + assert _validate_rules(schema, f'{prefix}ex:x a ex:Thing ; ex:guard "g" ; ex:target "4" .')[1] == {EX_RR.x} + + +EX_SINGLE = rdflib.Namespace("https://example.org/single-rule/") + + +def _single_rule_schema(rule: dict, attributes: dict | None = None, *, imports: bool = True) -> str: + """A schema whose class ``Thing`` has *attributes* and the one *rule* (JSON, which is YAML). + + By default the attributes are the string slots ``guard``, ``target`` and + ``other``, the boolean ``flag`` and the multivalued ``tags``. Without + *imports* the schema does not import ``linkml:types`` and has no + ``default_range``. + """ + schema = { + "id": "https://example.org/single-rule", + "name": "single_rule", + "prefixes": {"linkml": "https://w3id.org/linkml/", "ex": str(EX_SINGLE)}, + "default_prefix": "ex", + "classes": { + "Thing": { + "class_uri": "ex:Thing", + "attributes": attributes + or { + "guard": {"range": "string"}, + "target": {"range": "string"}, + "other": {"range": "string"}, + "flag": {"range": "boolean"}, + "tags": {"range": "string", "multivalued": True}, + }, + "rules": [rule], + } + }, + } + if imports: + schema["imports"] = ["linkml:types"] + schema["default_range"] = "string" + return json.dumps(schema) + + +def _rule(pre: dict, post: dict, **rule_fields) -> dict: + """A rule with the slot conditions *pre* and *post*.""" + return {"preconditions": {"slot_conditions": pre}, "postconditions": {"slot_conditions": post}, **rule_fields} + + +_GUARD_PRESENT = {"guard": {"value_presence": "PRESENT"}} + + +def _assert_skipped(caplog, schema: str, reason: str) -> None: + """Generating *schema* emits no rule constraint and warns that the rule was skipped for *reason*.""" + with caplog.at_level(logging.WARNING): + g = _parse_shacl(schema) + assert _sparql_queries(g, EX_SINGLE.Thing) == [] + assert any("skipped, because" in rec.message and reason in rec.message for rec in caplog.records), caplog.text + + +@pytest.mark.parametrize( + "target_range,value,allowed,other", + [ + pytest.param("boolean", "true", "true", "false", id="boolean-guard"), + pytest.param("string", "x", '"x"', '"y"', id="string"), + pytest.param("curie", "ex:a", '"ex:a"', '"ex:b"', id="curie"), + pytest.param(None, "x", '"x"', '"y"', id="no-range"), + ], +) +def test_rule_builtin_types_without_imports(target_range, value, allowed, other): + """In a schema that does not import ``linkml:types``, a built-in range name + resolves as the main slot loop resolves it, so the boolean guard and the + string comparison are both translated. A slot with no range at all (and + no ``default_range``) holds untyped values, compared as strings as the + JSON Schema generator compares them.""" + target = {} if target_range is None else {"range": target_range} + schema = _single_rule_schema( + _rule(_GUARD_PRESENT, {"target": {"equals_string": value}}), + {"guard": {"range": "string"}, "target": target}, + imports=False, + ) + assert len(_sparql_queries(_parse_shacl(schema), EX_SINGLE.Thing)) == 1 + prefix = "@prefix ex: .\n" + for term, violates in ((allowed, False), (other, True)): + data = f'{prefix}ex:x a ex:Thing ; ex:guard "g" ; ex:target {term} .' + assert _validate_rules(schema, data)[1] == ({EX_SINGLE.x} if violates else set()) + + +def test_rule_builtin_non_string_type_without_imports_skipped(caplog): + """A built-in non-string range without the ``linkml:types`` import is skipped like an imported one.""" + schema = _single_rule_schema( + _rule(_GUARD_PRESENT, {"target": {"equals_string": "3"}}), + {"guard": {"range": "string"}, "target": {"range": "integer"}}, + imports=False, + ) + _assert_skipped(caplog, schema, "is neither an enum nor a type with datatype xsd:string or xsd:boolean") + + +def test_curie_slot_without_imports_has_xsd_string_datatype(): + """A ``curie`` slot in a schema without the ``linkml:types`` import gets + ``sh:datatype xsd:string``, as with the import.""" + schema = _single_rule_schema( + _rule(_GUARD_PRESENT, {"target": {"equals_string": "ex:a"}}), + {"guard": {"range": "string"}, "target": {"range": "curie"}}, + imports=False, + ) + g = _parse_shacl(schema) + datatypes = { + o + for p in g.objects(EX_SINGLE.Thing, SH.property) + if (p, SH.path, EX_SINGLE.target) in g + for o in g.objects(p, SH.datatype) + } + assert datatypes == {XSD.string} + + +def test_rule_dynamic_enum_value_not_reported(caplog): + """An enum without static permissible values (a dynamic enum) can hold + values the schema does not list, so its equals_string value is not + reported as unknown.""" + + schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"equals_string": "GO:0008150"}}))) + schema["enums"] = {"Process": {"reachable_from": {"source_ontology": "obo:go", "source_nodes": ["GO:0008150"]}}} + schema["classes"]["Thing"]["attributes"]["target"] = {"range": "Process"} + with caplog.at_level(logging.WARNING): + queries = _sparql_queries(_parse_shacl(json.dumps(schema)), EX_SINGLE.Thing) + assert len(queries) == 1 and '"GO:0008150"' in queries[0] + assert not any("permissible value" in rec.message for rec in caplog.records), caplog.text + + +def test_rule_message_follows_default_language(): + """The rule description becomes ``sh:message``, tagged with the default + language like every other human-readable literal the generator emits.""" + rule = _rule(_GUARD_PRESENT, {"target": {"equals_string": "x"}}, description="The target must be x.") + g = _parse_shacl(_single_rule_schema(rule), default_language="en") + (node,) = g.objects(EX_SINGLE.Thing, SH.sparql) + assert set(g.objects(node, SH.message)) == {Literal("The target must be x.", lang="en")} + + +@pytest.mark.parametrize( + "descriptions,constraints", + [ + pytest.param(["Same.", "Same."], 1, id="identical-rules"), + pytest.param(["First.", "Second."], 2, id="different-messages"), + ], +) +def test_rule_identical_constraints_emitted_once(descriptions, constraints): + """A shape carries an identical constraint (query and message) once; rules + that differ in their message are kept apart.""" + schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"equals_string": "x"}}))) + rule = schema["classes"]["Thing"]["rules"][0] + schema["classes"]["Thing"]["rules"] = [{**rule, "description": text} for text in descriptions] + assert len(list(_parse_shacl(json.dumps(schema)).objects(EX_SINGLE.Thing, SH.sparql))) == constraints + + +def test_rule_problem_reported_once_for_inheriting_classes(caplog): + """A problem with a rule is reported once, naming the declaring class and + the rule's position, however many classes inherit it; and a skipped rule + with elseconditions is not also reported as partially emitted.""" + + schema = json.loads( + _single_rule_schema( + _rule(_GUARD_PRESENT, {"target": {"pattern": "^x"}}, elseconditions={"slot_conditions": {}}) + ) + ) + for name in ("C1", "C2", "C3"): + schema["classes"][name] = {"is_a": "Thing", "class_uri": f"ex:{name}"} + with caplog.at_level(logging.WARNING): + _parse_shacl(json.dumps(schema)) + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1, messages + assert "skipped, because" in messages[0] and "(in the shapes of 'Thing', 'C1', 'C2', 'C3')" in messages[0] + assert not any("elseconditions" in rec.message for rec in caplog.records), caplog.text + + +def test_rule_problem_names_every_affected_shape_whatever_the_class_order(caplog): + """A problem with an inherited rule names every class shape it affects, so + the report does not depend on the order in which classes are declared.""" + schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"pattern": "^x"}}))) + schema["classes"] = {"Sub": {"is_a": "Thing", "class_uri": "ex:Sub"}, **schema["classes"]} + with caplog.at_level(logging.WARNING): + _parse_shacl(json.dumps(schema)) + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1, messages + assert "'Sub'" in messages[0] and "'Thing'" in messages[0], messages[0] + + +def test_rule_problems_reported_per_rule_and_per_problem(caplog): + """Problems are kept apart by rule and by kind: two rules with the same + problem, and one rule with two problems, each produce their own warning. + Each warning names the rule by its position and description.""" + schema = json.loads(_single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"pattern": "^x"}}))) + schema["enums"] = {"Color": {"permissible_values": {"Red": {}, "Blue": {}}}} + schema["classes"]["Thing"]["attributes"]["color"] = {"range": "Color"} + schema["classes"]["Thing"]["rules"] = [ + _rule(_GUARD_PRESENT, {"target": {"pattern": "^x"}}, description="First"), + _rule(_GUARD_PRESENT, {"target": {"pattern": "^y"}}, description="Second"), + _rule( + _GUARD_PRESENT, + {"color": {"equals_string": "Purple"}}, + description="Third", + elseconditions={"slot_conditions": {"color": {"equals_string": "Red"}}}, + ), + ] + with caplog.at_level(logging.WARNING): + _parse_shacl(json.dumps(schema)) + messages = [rec.message for rec in caplog.records if "of class 'Thing'" in rec.message] + assert len([m for m in messages if m.startswith("Rule 1 of class 'Thing' (First): skipped")]) == 1, messages + assert len([m for m in messages if m.startswith("Rule 2 of class 'Thing' (Second): skipped")]) == 1, messages + third = [m for m in messages if m.startswith("Rule 3 of class 'Thing' (Third)")] + assert len(third) == 2 and any("elseconditions" in m for m in third), messages + assert any("'Purple', which is not a permissible value" in m for m in third), messages + + +def test_rule_first_unknown_slot_is_the_reported_problem(caplog): + """A rule is skipped at its first untranslatable condition: with an + unknown guard and an unknown target, only the guard is reported.""" + schema = _single_rule_schema( + _rule({"no_such_guard": {"value_presence": "PRESENT"}}, {"no_such_target": {"equals_string": "x"}}) + ) + with caplog.at_level(logging.WARNING): + _parse_shacl(schema) + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1 and "no_such_guard" in messages[0], messages + + +def test_exclusive_value_bare_equals_string_on_single_valued_slot_does_not_warn(caplog): + """On a single-valued slot, "the value equals V" and "one of the values + equals V" coincide, so the bare form is translated without a warning.""" + schema = _single_rule_schema(_rule({"target": {"equals_string": "x"}}, {"target": {"maximum_cardinality": 1}})) + with caplog.at_level(logging.WARNING): + assert len(_sparql_queries(_parse_shacl(schema), EX_SINGLE.Thing)) == 1 + assert not any("has_member" in rec.message for rec in caplog.records), caplog.text + + +def test_rule_message_follows_rule_language(): + """A rule's own ``in_language`` takes precedence over the default language.""" + rule = _rule(_GUARD_PRESENT, {"target": {"equals_string": "x"}}, description="Das Ziel muss x sein.") + rule["in_language"] = "de" + g = _parse_shacl(_single_rule_schema(rule), default_language="en") + (node,) = g.objects(EX_SINGLE.Thing, SH.sparql) + assert set(g.objects(node, SH.message)) == {Literal("Das Ziel muss x sein.", lang="de")} + + +@pytest.mark.parametrize( + "rule", + [ + pytest.param( + _rule({"no_such_slot": {"value_presence": "PRESENT"}}, {"flag": {"equals_string": "true"}}), + id="boolean-guard-unknown-guard", + ), + pytest.param( + _rule({"no_such_slot": {"value_presence": "PRESENT"}}, {"target": {"equals_string": "x"}}), + id="presence-implies-value-unknown-guard", + ), + pytest.param( + _rule(_GUARD_PRESENT, {"no_such_slot": {"equals_string": "x"}}), + id="presence-implies-value-unknown-target", + ), + pytest.param( + _rule({"no_such_slot": {"equals_string": "x"}}, {"no_such_slot": {"maximum_cardinality": 1}}), + id="exclusive-value-unknown-slot", + ), + ], +) +def test_rule_unknown_slot_key_skipped(caplog, rule): + """A rule whose condition names a slot that does not exist is skipped: + a fabricated predicate would make a constraint that never fires (unknown + guard) or always fires (unknown target).""" + _assert_skipped(caplog, _single_rule_schema(rule), "'no_such_slot', which is not a slot") + + +@pytest.mark.parametrize( + "rule", + [ + pytest.param(_rule({"id": {"value_presence": "PRESENT"}}, {"target": {"equals_string": "x"}}), id="guard"), + pytest.param(_rule(_GUARD_PRESENT, {"id": {"equals_string": "x"}}), id="target"), + ], +) +def test_rule_identifier_slot_skipped(caplog, rule): + """The identifier is the node's IRI, not a property arc, so a condition on + it can never match a triple and the rule is skipped.""" + attributes = {"id": {"identifier": True, "range": "uriorcurie"}, "guard": {}, "target": {}} + _assert_skipped(caplog, _single_rule_schema(rule, attributes), "identifier slot 'id'") + + +_ESCAPING_SCHEMA_YAML = """ +id: https://example.org/escaping +name: escaping_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/escaping/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +slots: + trigger: + range: string + slot_uri: ex:trigger + label: + range: string + slot_uri: ex:label + +classes: + Item: + class_uri: ex:Item + slots: + - trigger + - label + rules: + - description: If a trigger is present the label must equal the quoted marker. + preconditions: + slot_conditions: + trigger: + value_presence: PRESENT + postconditions: + slot_conditions: + label: + equals_string: 'a"b\\\\c' +""" + +EX_ESC = rdflib.Namespace("https://example.org/escaping/") + + +def test_rule_equals_string_special_chars_escaped(): + """An equals_string value with a quote and backslash must be escaped so the + generated SPARQL stays syntactically valid (no injection / broken query).""" + from rdflib.plugins.sparql import prepareQuery + + queries = _sparql_queries(_parse_shacl(_ESCAPING_SCHEMA_YAML), EX_ESC.Item) + assert len(queries) == 1 + query = queries[0] + + # Would raise ParseException on the unescaped `... = "a"b\c"` form. + prepareQuery(query) + assert '\\"' in query, f"double quote must be escaped, got:\n{query}" + assert "\\\\" in query, f"backslash must be escaped, got:\n{query}" + + +# =========================================================================== +# Operator exactness +# +# A rule is translated only when its conditions set exactly the operators a +# pattern translates. Each rule below would match a pattern if the converter +# dropped the extra operator, which would widen the precondition (false +# positives) or weaken the postcondition (false negatives). +# =========================================================================== + + +@pytest.mark.parametrize( + "extra", + [ + pytest.param({"pattern": "^x"}, id="pattern"), + pytest.param({"minimum_value": 0}, id="minimum_value"), + pytest.param({"maximum_value": 5}, id="maximum_value"), + pytest.param({"required": True}, id="required"), + pytest.param({"recommended": True}, id="recommended"), + pytest.param({"minimum_cardinality": 1}, id="minimum_cardinality"), + pytest.param({"exact_cardinality": 1}, id="exact_cardinality"), + pytest.param({"equals_number": 3}, id="equals_number"), + pytest.param({"has_member": {"equals_string": "x"}}, id="has_member"), + pytest.param({"any_of": [{"equals_string": "x"}]}, id="slot-any_of"), + ], +) +@pytest.mark.parametrize("condition", ["guard", "target"]) +def test_rule_extra_condition_operator_skipped(caplog, condition, extra): + """A guard or target condition that adds any operator to the ones a pattern + translates makes the rule untranslatable.""" + guard = {"value_presence": "PRESENT", **(extra if condition == "guard" else {})} + target = {"equals_string": "x", **(extra if condition == "target" else {})} + schema = _single_rule_schema(_rule({"guard": guard}, {"target": target})) + _assert_skipped(caplog, schema, "match none of the translated patterns") + + +@pytest.mark.parametrize("presence", ["ABSENT", "UNCOMMITTED"]) +def test_rule_guard_presence_other_than_present_skipped(caplog, presence): + """Only ``value_presence: PRESENT`` is a guard: reading ``ABSENT`` as a + guard would invert the trigger.""" + schema = _single_rule_schema(_rule({"guard": {"value_presence": presence}}, {"target": {"equals_string": "x"}})) + _assert_skipped(caplog, schema, "match none of the translated patterns") + + +@pytest.mark.parametrize("operator", ["any_of", "all_of", "exactly_one_of", "none_of"]) +@pytest.mark.parametrize("side", ["preconditions", "postconditions"]) +def test_rule_expression_level_operator_skipped(caplog, side, operator): + """An expression-level boolean operator on either side cannot be honoured + by any pattern; dropping it would widen or weaken the rule.""" + rule = _rule(_GUARD_PRESENT, {"target": {"equals_string": "x"}}) + rule[side][operator] = [{"slot_conditions": {"other": {"equals_string": "y"}}}] + _assert_skipped(caplog, _single_rule_schema(rule), f"its {side} use {operator}") + + +def test_rule_post_with_both_equals_forms_skipped(caplog): + """equals_string and equals_string_in set together is ambiguous — skip, + do not let one form silently win.""" + schema = _single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"equals_string": "a", "equals_string_in": ["b"]}})) + _assert_skipped(caplog, schema, "match none of the translated patterns") + + +def test_rule_exclusive_value_extra_operator_skipped(caplog): + """The exclusive-value pattern is exact too: a precondition mixing + equals_string with an unsupported operator is skipped.""" + schema = _single_rule_schema( + _rule({"tags": {"equals_string": "x", "pattern": "^x"}}, {"tags": {"maximum_cardinality": 1}}) + ) + _assert_skipped(caplog, schema, "match none of the translated patterns") + + +_STRING_TRUE_SCHEMA_YAML = """ +id: https://example.org/string-true +name: string_true +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/string-true/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + opt: + slot_uri: ex:opt + status: + range: string + slot_uri: ex:status +classes: + Conf: + class_uri: ex:Conf + slots: [opt, status] + rules: + - description: If opt is present, status must be the string "true". + preconditions: + slot_conditions: + opt: + value_presence: PRESENT + postconditions: + slot_conditions: + status: + equals_string: "true" +""" + + +def test_rule_equals_true_on_string_slot_uses_piv(): + """equals_string "true" on a NON-boolean slot is compared as the string + "true", not as the boolean of the boolean guard.""" + queries = _sparql_queries(_parse_shacl(_STRING_TRUE_SCHEMA_YAML), URIRef("https://example.org/string-true/Conf")) + assert len(queries) == 1 + assert '?value = "true"' in queries[0], f"string-range 'true' must be a string comparison, got:\n{queries[0]}" + assert "?value = true" not in queries[0], "the boolean guard must not apply to a string slot" + + +@pytest.mark.parametrize( + "status,violates", + [ + pytest.param('"true"', False, id="plain"), + pytest.param('"true"^^xsd:string', False, id="xsd-string"), + pytest.param('"other"', True, id="other"), + ], +) +def test_rule_equals_true_on_string_slot_pyshacl_end_to_end(status, violates): + """status "true" (string) satisfies the rule in either RDF 1.1-identical form.""" + data = ( + "@prefix ex: .\n" + "@prefix xsd: .\n" + f'ex:x a ex:Conf ; ex:opt "x" ; ex:status {status} .' + ) + _, focus_nodes = _validate_rules(_STRING_TRUE_SCHEMA_YAML, data) + assert focus_nodes == ({URIRef("https://example.org/string-true/x")} if violates else set()) diff --git a/tests/linkml/test_validator/test_shacl_validation_plugin.py b/tests/linkml/test_validator/test_shacl_validation_plugin.py index 19d7f5d9be..ba912166b0 100644 --- a/tests/linkml/test_validator/test_shacl_validation_plugin.py +++ b/tests/linkml/test_validator/test_shacl_validation_plugin.py @@ -11,6 +11,9 @@ # not re-exported, so that pyshacl stays an optional dependency. from linkml.validator.plugins.shacl_validation_plugin import ShaclValidationPlugin from linkml.validator.report import Severity +from linkml.validator.validation_context import ValidationContext +from linkml_runtime.linkml_model import SchemaDefinition +from linkml_runtime.loaders import yaml_loader def test_conforming_instance_yields_no_results(validation_context): @@ -87,3 +90,88 @@ def test_generated_shapes_are_cached(validation_context): list(plugin.process({"id": "P:1", "name": "One"}, validation_context)) assert len(plugin._loaded_graphs) == 1 + + +_RULES_SCHEMA_YAML = """ +id: https://example.org/plugin-rules +name: plugin_rules +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/plugin-rules/ +imports: + - linkml:types +default_prefix: ex +default_range: string +enums: + Color: + permissible_values: + Red: + meaning: ex:Red + Blue: + meaning: ex:Blue + Code: + permissible_values: + alpha: + beta: +classes: + Base: + attributes: + id: + identifier: true + guard: {} + label: {} + color: + range: Color + code: + range: Code + rules: + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + label: + equals_string: x + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + color: + equals_string: Red + - preconditions: + slot_conditions: + guard: + value_presence: PRESENT + postconditions: + slot_conditions: + code: + equals_string_in: [alpha] + Child: + is_a: Base +""" + + +@pytest.mark.parametrize("target_class", ["Base", "Child"]) +@pytest.mark.parametrize( + "instance,violates", + [ + pytest.param({"guard": "g", "label": "x", "color": "Red", "code": "alpha"}, False, id="conforming"), + pytest.param({"guard": "g", "label": "y", "color": "Red", "code": "alpha"}, True, id="string"), + pytest.param({"guard": "g", "label": "x", "color": "Blue", "code": "alpha"}, True, id="enum-meaning"), + pytest.param({"guard": "g", "label": "x", "color": "Red", "code": "beta"}, True, id="enum-literal"), + pytest.param({"guard": "g", "color": "Red", "code": "alpha"}, True, id="target-absent"), + pytest.param({"label": "y", "color": "Blue", "code": "beta"}, False, id="unguarded"), + ], +) +def test_rule_violation_is_reported(target_class, instance, violates): + """Rules translated to ``sh:sparql`` are enforced through the plugin, whose + data graph comes from the LinkML RDF dumper, on instances of the declaring + class and of its subclasses alike.""" + context = ValidationContext(yaml_loader.loads(_RULES_SCHEMA_YAML, SchemaDefinition), target_class) + results = list(ShaclValidationPlugin().process({"id": "ex:x", **instance}, context)) + + assert all(result.severity is Severity.ERROR for result in results) + assert any("SPARQLConstraintComponent" in result.message for result in results) is violates From f1c04d0821233b20ef5b839493f381d0f2b0ff81 Mon Sep 17 00:00:00 2001 From: Rayene Messaoud Date: Tue, 6 Oct 2026 15:45:50 +0200 Subject: [PATCH 17/30] feat(gen-shacl): add a compositional fallback for rule-to-SPARQL conversion A rule that no named pattern matches is now composed from the operators it uses: each precondition becomes SPARQL filters on $this, and the single postcondition slot the union of the ways to violate it, binding the offending value to ?value (SHACL sections 5.3.1 and 5.3.2). A condition may use value_presence, required, equals_string, equals_string_in, minimum_value, maximum_value, a range_expression on the slot's class (nested to any depth) and has_member. The semantics follow the JSON Schema generator's if/then: - presence: value_presence decides, then required, then a default: a precondition requires its slot, a postcondition unless the rule is open_world, an inner condition of a nested expression never; - value operators and range_expression apply to every value of the slot (05validation.md: "all members"), has_member to some value; - equals_string(_in) only on string, enum and boolean slots (the named patterns' range check), bounds only on the numeric datatypes of SPARQL 1.1 section 17.1, guarded with isNumeric, and only finite numbers. Any other operator, a rule with empty pre- or postconditions or several postcondition slots, skips the rule with one warning, so a rule is never partially translated. A rule skipped part-way no longer reports the notes of the abandoned translation. Co-authored-by: jdsika --- .../linkml/src/linkml/generators/shaclgen.py | 405 ++++++- tests/linkml/test_generators/test_shaclgen.py | 1040 ++++++++++++++++- 2 files changed, 1399 insertions(+), 46 deletions(-) diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index c6501ddced..a587b0fd62 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -1,4 +1,5 @@ import logging +import math import os import re import string @@ -70,6 +71,11 @@ def _validate_message_template(template: str) -> None: ) +_PRESENT = PresenceEnum(PresenceEnum.PRESENT) +_ABSENT = PresenceEnum(PresenceEnum.ABSENT) +_UNCOMMITTED = PresenceEnum(PresenceEnum.UNCOMMITTED) + + @dataclass class _RuleSite: """A rule as it is translated for the shape of a class. @@ -180,9 +186,9 @@ class ShaclGenerator(Generator): emit_rules: bool = True """Emit ``sh:sparql`` constraints from LinkML ``rules:`` blocks. - When ``True`` (default), recognised rule patterns are translated into - SHACL-SPARQL constraints (``sh:SPARQLConstraint``) on the corresponding - ``sh:NodeShape``. Currently two patterns are recognised: + When ``True`` (default), rules are translated into SHACL-SPARQL + constraints (``sh:SPARQLConstraint``) on the corresponding + ``sh:NodeShape``. Two patterns are recognised first: * *Presence implies value* — a precondition with ``value_presence: PRESENT`` on a guard slot and a postcondition with ``equals_string`` or @@ -193,8 +199,11 @@ class ShaclGenerator(Generator): (or a bare ``equals_string: V``) on a slot and a postcondition with ``maximum_cardinality`` on the *same* slot. - A rule that cannot be translated exactly is skipped with a warning. A - class shape carries the rules of the class's ancestors and mixins too. + Any other rule with one postcondition slot is composed from the operators + its conditions use (presence, value comparisons, numeric bounds, + ``range_expression``, ``has_member``). A rule that cannot be translated + exactly is skipped with a warning. A class shape carries the rules of the + class's ancestors and mixins too. See `W3C SHACL §5 `_ and `linkml/linkml#2464 `_. @@ -485,11 +494,17 @@ def _add_rules(self, g: Graph, shape_uri: URIRef, cls: ClassDefinition) -> None: specification applies ``equals_string`` to all members of a collection. + * **Composed** — any other rule with one postcondition slot is + composed from the operators its conditions use: presence, + ``equals_string(_in)`` and numeric bounds on each value, a + ``range_expression`` on the slot's class, and ``has_member`` (see + :meth:`_compose_rule_sparql`). + Apart from that reading, every emitted constraint translates its rule exactly. A rule that cannot be translated exactly is skipped with a - warning naming the reason: an operator combination outside these - patterns, a condition on an unknown or identifier slot, a value the - target's range cannot hold (see :meth:`_value_terms`), or + warning naming the reason: an operator the translations do not + support, a condition on an unknown or identifier slot, a value the + slot's range cannot hold (see :meth:`_value_terms`), or ``bidirectional``. Of a rule with ``elseconditions`` the forward (if/then) direction is emitted, exactly, and a warning reports the else branch as not enforced. @@ -565,7 +580,17 @@ def _report_rule_problems(self) -> None: logger.warning("%s: %s%s.", rule, problem, shapes) def _skip_rule(self, site: _RuleSite, reason: str) -> None: - """Log that the rule at *site* is not translated, and why.""" + """Log that the rule at *site* is not translated, and why. + + This replaces the problems recorded so far while translating the rule + for the shape of ``site.cls``, which describe a constraint that is not + emitted. + """ + for key, (_, classes) in list(self._rule_problems.items()): + if key[:2] == (site.owner, site.index) and site.cls.name in classes: + classes.remove(site.cls.name) + if not classes: + del self._rule_problems[key] self._warn_rule(site, f"skipped, because {reason}") # Fields on a slot condition / class expression that carry no constraint @@ -586,7 +611,12 @@ def _skip_rule(self, site: _RuleSite, reason: str) -> None: # as SPARQL boolean literals. _XSD_BOOLEAN_LEXICAL = {"true": "true", "1": "true", "false": "false", "0": "false"} - _NO_PATTERN = "its conditions match none of the translated patterns (presence implies value, exclusive value)" + # Prefixes the reason a rule is skipped when it uses an operator, or a + # combination of conditions, that no translation supports. + _NO_PATTERN = ( + "its conditions match none of the translated patterns (presence implies value, exclusive value, " + "and the compositional translation)" + ) @classmethod def _set_operator_fields( @@ -636,9 +666,11 @@ def _rule_to_sparql(self, site: _RuleSite) -> str | None: if untranslated: self._skip_rule(site, f"its {side} use {', '.join(sorted(untranslated))}") return None + if not expression.slot_conditions: + self._skip_rule(site, f"its {side} constrain no slot") + return None if len(pre.slot_conditions) != 1 or len(post.slot_conditions) != 1: - self._skip_rule(site, self._NO_PATTERN) - return None + return self._compose_rule_sparql(site, pre.slot_conditions, post.slot_conditions) ((pre_name, pre_cond),) = pre.slot_conditions.items() ((post_name, post_cond),) = post.slot_conditions.items() @@ -654,7 +686,7 @@ def _rule_to_sparql(self, site: _RuleSite) -> str | None: # the case `equals_string: "true"` on a boolean flag. if ( pre_ops == {"value_presence"} - and pre_cond.value_presence == PresenceEnum(PresenceEnum.PRESENT) + and pre_cond.value_presence == _PRESENT and post_ops in ({"equals_string"}, {"equals_string_in"}) ): values = [post_cond.equals_string] if post_ops == {"equals_string"} else list(post_cond.equals_string_in) @@ -673,8 +705,7 @@ def _rule_to_sparql(self, site: _RuleSite) -> str | None: return None return self._build_exclusive_value_sparql(pre_slot, terms[0], int(post_cond.maximum_cardinality)) - self._skip_rule(site, self._NO_PATTERN) - return None + return self._compose_rule_sparql(site, pre.slot_conditions, post.slot_conditions) def _exclusive_value( self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, operators: set[str] @@ -687,6 +718,7 @@ def _exclusive_value( the same way, as the pattern always has, with a warning: the specification applies a slot constraint to all members of a collection (``05validation.md``), under which the rule would mean something else. + Every other rule reads it that way (:meth:`_compose_rule_sparql`). """ if operators == {"has_member"} and self._set_operator_fields(condition.has_member) == {"equals_string"}: return condition.has_member.equals_string @@ -701,6 +733,340 @@ def _exclusive_value( return condition.equals_string return None + # Operators the compositional translation supports on a condition: those a + # value is tested against, and those stating whether the slot is present or + # some value satisfies a condition. + _VALUE_OPERATORS = frozenset( + {"equals_string", "equals_string_in", "minimum_value", "maximum_value", "range_expression"} + ) + _CONDITION_OPERATORS = _VALUE_OPERATORS | {"required", "value_presence", "has_member"} + + # The datatypes SPARQL compares numerically: the numeric types and the types + # derived from them (SPARQL 1.1 §17.1, + # ). + _SPARQL_NUMERIC_DATATYPES = frozenset( + str(XSD[name]) + for name in ( + "integer", + "decimal", + "float", + "double", + "nonPositiveInteger", + "negativeInteger", + "long", + "int", + "short", + "byte", + "nonNegativeInteger", + "unsignedLong", + "unsignedInt", + "unsignedShort", + "unsignedByte", + "positiveInteger", + ) + ) + + def _compose_rule_sparql( + self, site: _RuleSite, pre: dict[str, SlotDefinition], post: dict[str, SlotDefinition] + ) -> str | None: + """Compose the query of a rule no named pattern matches from its slot conditions *pre* and *post*. + + The caller passes them after checking that the pre- and postconditions + set nothing else (:meth:`_rule_to_sparql`). Each precondition becomes + filters on ``$this``, and the single postcondition the union of the + ways to violate it, so the query selects the focus nodes that satisfy + every precondition and violate the postcondition (`SHACL §5.3.1 + `_), with + the offending value where there is one (`SHACL §5.3.2 + `_). + + Whether a condition requires its slot is decided by :meth:`_presence`. + Its value operators and ``range_expression`` apply to every value of + the slot: a slot constraint applies to all members of a collection + (``05validation.md``), as the JSON Schema generator applies it through + ``items``. ``has_member`` requires some value to satisfy its + condition. A condition may use :data:`_CONDITION_OPERATORS`; any other + operator skips the rule, as does an untranslatable value. + """ + if len(post) != 1: + self._skip_rule(site, f"{self._NO_PATTERN}, and its postconditions constrain more than one slot") + return None + lines: list[str] = [] + for index, (slot_name, condition) in enumerate(pre.items()): + slot = self._rule_condition_slot(site, slot_name) + if slot is None: + return None + filters = self._precondition_filters(site, slot, condition, f"?pre{index}") + if filters is None: + return None + lines.extend(filters) + ((post_name, post_cond),) = post.items() + post_slot = self._rule_condition_slot(site, post_name) + if post_slot is None: + return None + violations = self._postcondition_violations(site, post_slot, post_cond, bool(site.rule.open_world)) + if violations is None: + return None + if len(violations) == 1: + lines.extend(violations) + else: + lines.append(f"{{ {violations[0]} }}") + lines.extend(f"UNION {{ {violation} }}" for violation in violations[1:]) + body = "\n".join(f" {line}" for line in lines) + return f"SELECT DISTINCT $this (<{self._slot_iri(post_slot)}> AS ?path) ?value WHERE {{\n{body}\n}}" + + @staticmethod + def _presence(condition: SlotDefinition, default: PresenceEnum) -> PresenceEnum: + """Whether the rule *condition* requires its slot to be ``PRESENT``, ``ABSENT``, or neither (``UNCOMMITTED``). + + ``value_presence`` decides, then ``required``, then *default*, as the + JSON Schema generator decides whether a rule condition requires its + property: by default a precondition does, a postcondition unless the + rule is ``open_world`` ("the postconditions may be omitted in instance + data", metamodel), and an inner condition of a nested expression never. + """ + if condition.value_presence is not None: + return PresenceEnum(condition.value_presence) + if condition.required is not None: + return _PRESENT if condition.required else _UNCOMMITTED + return default + + def _precondition_filters( + self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, var: str + ) -> list[str] | None: + """The SPARQL filters that hold when *slot* of ``$this`` satisfies the precondition *condition*: + the slot is present (by default) or absent as :meth:`_presence` decides, and nothing violates it.""" + checked = self._condition_violations(site, slot, condition, "$this", var, _PRESENT, "precondition") + if checked is None: + return None + presence, violations = checked + value = f"$this <{self._slot_iri(slot)}> {var} ." + filters: list[str] = [] + if presence == _PRESENT: + filters.append(f"FILTER EXISTS {{ {value} }}") + elif presence == _ABSENT: + filters.append(f"FILTER NOT EXISTS {{ {value} }}") + return filters + [f"FILTER NOT EXISTS {{ {violation} }}" for violation in violations] + + def _postcondition_violations( + self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, open_world: bool + ) -> list[str] | None: + """The patterns by which ``$this`` violates the postcondition *condition* on *slot*. + + A pattern matching an offending value binds it to ``?value``. With + *open_world* "the postconditions may be omitted in instance data" + (metamodel ``open_world``), so the slot is not required by default. + """ + default = _UNCOMMITTED if open_world else _PRESENT + checked = self._condition_violations(site, slot, condition, "$this", "?value", default, "postcondition") + if checked is None: + return None + presence, violations = checked + presence_violation = self._presence_violation("$this", slot, "?value", presence) + violations = [presence_violation, *violations] if presence_violation else violations + if not violations: + self._skip_rule(site, f"its postcondition on slot {slot.name!r} constrains nothing") + return None + return violations + + def _presence_violation(self, subject: str, slot: SlotDefinition, var: str, presence: PresenceEnum) -> str | None: + """The pattern matching *subject* when its *slot* violates *presence*, binding *var* to a value that must + be absent; ``None`` when *presence* is ``UNCOMMITTED``.""" + value = f"{subject} <{self._slot_iri(slot)}> {var} ." + if presence == _PRESENT: + return f"FILTER NOT EXISTS {{ {value} }}" + if presence == _ABSENT: + return value + return None + + def _condition_violations( + self, + site: _RuleSite, + slot: SlotDefinition, + condition: SlotDefinition, + subject: str, + var: str, + default: PresenceEnum, + role: str, + ) -> tuple[PresenceEnum, list[str]] | None: + """The presence the *role* *condition* on *slot* of *subject* requires, and its other violations. + + A value bound to *var* violates the value operators or the + ``range_expression`` (:meth:`_value_violations`); ``has_member`` is + violated when the slot has values and none satisfies the member + condition. + """ + operators = self._set_operator_fields(condition) + if not operators <= self._CONDITION_OPERATORS: + self._skip_rule(site, self._unsupported(slot, operators, self._CONDITION_OPERATORS, role)) + return None + violations = self._value_violations(site, slot, condition, subject, var) + if violations is None: + return None + member_condition = condition.has_member + if member_condition is not None: + member_operators = self._set_operator_fields(member_condition) + if not member_operators or not member_operators <= self._VALUE_OPERATORS: + self._skip_rule(site, self._unsupported(slot, member_operators, self._VALUE_OPERATORS, "has_member")) + return None + member = f"{var}_member" + member_violations = self._value_violations(site, slot, member_condition, subject, member) + if member_violations is None: + return None + iri = self._slot_iri(slot) + satisfied = "".join(f" FILTER NOT EXISTS {{ {violation} }}" for violation in member_violations) + violations.append( + f"FILTER EXISTS {{ {subject} <{iri}> {var}_any . }} " + f"FILTER NOT EXISTS {{ {subject} <{iri}> {member} .{satisfied} }}" + ) + return self._presence(condition, default), violations + + def _value_violations( + self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, subject: str, var: str + ) -> list[str] | None: + """The patterns matching a value *var* of *slot* of *subject* that violates a value operator of *condition*. + + The value operators are tested on the value itself, a + ``range_expression`` on the value's own slots + (:meth:`_nested_violations`). + """ + value = f"{subject} <{self._slot_iri(slot)}> {var} ." + violations: list[str] = [] + if condition.range_expression is not None: + nested = self._nested_violations(site, slot, condition.range_expression, var) + if nested is None: + return None + violations.extend(f"{value} {violation}" for violation in nested) + test = self._value_test(site, slot, condition, var) + if test is None: + return None + if test: + violations.append(f"{value} FILTER ( !( {test} ) )") + return violations + + def _nested_violations( + self, site: _RuleSite, slot: SlotDefinition, expression: AnonymousClassExpression, node: str + ) -> list[str] | None: + """The patterns by which an object *node*, a value of *slot*, violates the class expression *expression*. + + *expression* must consist of slot conditions on the slot's range class, + resolved in that class's induced context. Each one holds for an absent + inner slot unless it requires the slot (:meth:`_presence`), as a slot + constraint applies to the values present and as the JSON Schema + generator reads a nested expression. On a reference that is not + inlined, the conditions apply to the referenced node's triples in the + data graph. + """ + sv = self.schemaview + operators = self._set_operator_fields(expression) + if operators != {"slot_conditions"}: + self._skip_rule( + site, self._unsupported(slot, operators, frozenset({"slot_conditions"}), "range_expression") + ) + return None + range_name = self._slot_range(slot) + if range_name not in sv.all_classes(): + self._skip_rule(site, f"its range_expression is on slot {slot.name!r}, whose range is not a class") + return None + range_class = sv.get_class(range_name) + violations: list[str] = [] + for index, (inner_name, condition) in enumerate(expression.slot_conditions.items()): + inner = self._rule_condition_slot(site, inner_name, range_class) + if inner is None: + return None + var = f"{node}_{index}" + checked = self._condition_violations(site, inner, condition, node, var, _UNCOMMITTED, "inner condition") + if checked is None: + return None + presence, inner_violations = checked + presence_violation = self._presence_violation(node, inner, var, presence) + if presence_violation: + violations.append(presence_violation) + violations.extend(inner_violations) + return violations + + def _value_test(self, site: _RuleSite, slot: SlotDefinition, condition: SlotDefinition, var: str) -> str | None: + """The SPARQL test that a value *var* of *slot* satisfies the value operators of *condition*. + + Returns ``""`` when *condition* sets no value operator, and ``None`` + (rule skipped) when a value cannot be translated. ``equals_string`` + and ``equals_string_in`` compare as :meth:`_value_terms` renders them. + ``minimum_value`` / ``maximum_value`` are inclusive bounds (metamodel), + compared numerically, so the slot's datatype must be one SPARQL + compares as a number (:data:`_SPARQL_NUMERIC_DATATYPES`). Comparing a + value of another type with a number is a type error (`SPARQL 1.1 §17.3 + `_), which some + engines resolve as an ordering anyway, so `isNumeric + `_ guards the + comparison; an engine that takes an ill-typed literal such as + ``"abc"^^xsd:integer`` for a number still compares it. Every part is + false, rather than an error, for a value it cannot compare (see + :meth:`_sparql_is_one_of`). + """ + parts: list[str] = [] + for values in ( + [condition.equals_string] if condition.equals_string is not None else None, + list(condition.equals_string_in) if condition.equals_string_in else None, + ): + if values is None: + continue + terms = self._value_terms(site, slot, values) + if terms is None: + return None + parts.append(self._sparql_is_one_of(var, terms)) + bounds = { + name: (bound, operator) + for name, bound, operator in ( + ("minimum_value", condition.minimum_value, ">="), + ("maximum_value", condition.maximum_value, "<="), + ) + if bound is not None + } + r = self._slot_range(slot) + if bounds and self._type_uri(r) not in self._SPARQL_NUMERIC_DATATYPES: + self._skip_rule( + site, + f"its {' and '.join(bounds)} on slot {slot.name!r} {'need' if len(bounds) > 1 else 'needs'} a type " + "with a numeric datatype, and " + (f"the slot's range is {r!r}" if r else "the slot has no range"), + ) + return None + comparisons: list[str] = [] + for bound, operator in bounds.values(): + number = self._sparql_number(bound) + if number is None: + self._skip_rule(site, f"its bound {bound!r} on slot {slot.name!r} is not a finite number") + return None + comparisons.append(f"{var} {operator} {number}") + if comparisons: + parts.append(f"COALESCE( {' && '.join([f'isNumeric( {var} )', *comparisons])}, false )") + return " && ".join(parts) + + @staticmethod + def _sparql_number(value: object) -> str | None: + """Render a ``minimum_value`` / ``maximum_value`` bound as a SPARQL numeric literal, or ``None``. + + The metamodel range of both is ``Anything``, so strings, dates, + booleans and ``.nan`` / ``.inf`` reach the generator unchanged. + Interpolated raw, ``"abc"`` makes the query unparsable and a date such + as ``2020-01-01`` parses as arithmetic; only ``int`` and finite + ``float`` values are rendered, and their ``str`` is a plain numeric + token that SPARQL compares with numeric type promotion. + """ + if isinstance(value, bool) or not isinstance(value, int | float): + return None + if isinstance(value, float) and not math.isfinite(value): + return None + return str(value) + + @classmethod + def _unsupported(cls, slot: SlotDefinition, operators: set[str], supported: frozenset[str], role: str) -> str: + """The reason a condition with *operators* in *role* on *slot* is not translated.""" + used = ", ".join(sorted(operators)) or "no operator" + return ( + f"{cls._NO_PATTERN}; its {role} on slot {slot.name!r} uses {used}, where the compositional " + f"translation supports only {', '.join(sorted(supported))}" + ) + def _rule_slot(self, cls: ClassDefinition, slot_name: str) -> SlotDefinition | None: """Resolve a rule condition's slot key to the slot it names, or ``None`` when no such slot exists. @@ -720,15 +1086,20 @@ def _rule_slot(self, cls: ClassDefinition, slot_name: str) -> SlotDefinition | N return sv.induced_slot(canonical, cls.name) return sv.get_slot(slot_name) - def _rule_condition_slot(self, site: _RuleSite, slot_name: str) -> SlotDefinition | None: + def _rule_condition_slot( + self, site: _RuleSite, slot_name: str, cls: ClassDefinition | None = None + ) -> SlotDefinition | None: """The slot a condition of the rule at *site* names, or ``None`` (rule skipped) when it cannot be queried. + The slot is resolved in the context of ``site.cls``, or of *cls* for an + inner condition on the range class of a nested expression. + An unknown name would make the query use a predicate no shape or data uses. An identifier slot is the node's IRI, not a property arc (the main slot loop does not require it either), so a condition on it can never match. """ - slot = self._rule_slot(site.cls, slot_name) + slot = self._rule_slot(cls if cls is not None else site.cls, slot_name) if slot is None: self._skip_rule(site, f"its condition names {slot_name!r}, which is not a slot") return None diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index 2339b941e5..468961d546 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -3391,17 +3391,24 @@ def test_rule_equals_string_agrees_with_json_schema( @pytest.mark.parametrize("instance,valid_closed_world,valid_open_world", _RULE_INSTANCES) @pytest.mark.parametrize("target_class", ["Thing", "Sub"]) @pytest.mark.parametrize("open_world", [False, True]) +@pytest.mark.parametrize( + "guard_extra", [None, {"required": True}], ids=["presence-implies-value", "composed-equivalent"] +) def test_rule_inheritance_and_open_world_agree_with_json_schema( - target_range, value, other, instance, valid_closed_world, valid_open_world, target_class, open_world + target_range, value, other, instance, valid_closed_world, valid_open_world, target_class, open_world, guard_extra ): """A rule binds the instances of subclasses of its class, and ``open_world`` lets the postcondition be omitted, in SHACL as in JSON Schema. The metamodel defines ``open_world`` as "the postconditions may be omitted - in instance data", so an absent target satisfies an open-world rule. + in instance data", so an absent target satisfies an open-world rule. A + guard that also states ``required: true`` means the same and is composed + rather than matched by the named pattern, with the same verdicts. """ obj = {key: text.format(value=value, other=other) for key, text in instance.items()} - schema = _rule_target_schema(target_range, value, open_world=open_world) + schema = _rule_target_schema(target_range, value, open_world=open_world, guard_extra=guard_extra) + (query,) = _sparql_queries(_parse_shacl(schema), EX_RTR[target_class]) + assert ("?pre0" in query) == (guard_extra is not None), query # the composed form filters on ?pre0 valid = valid_open_world if open_world else valid_closed_world assert _json_schema_and_shacl_verdicts(schema, target_class, obj) == (valid, valid) @@ -4443,36 +4450,75 @@ def test_rule_equals_string_special_chars_escaped(): @pytest.mark.parametrize( - "extra", + "extra,reason", [ - pytest.param({"pattern": "^x"}, id="pattern"), - pytest.param({"minimum_value": 0}, id="minimum_value"), - pytest.param({"maximum_value": 5}, id="maximum_value"), - pytest.param({"required": True}, id="required"), - pytest.param({"recommended": True}, id="recommended"), - pytest.param({"minimum_cardinality": 1}, id="minimum_cardinality"), - pytest.param({"exact_cardinality": 1}, id="exact_cardinality"), - pytest.param({"equals_number": 3}, id="equals_number"), - pytest.param({"has_member": {"equals_string": "x"}}, id="has_member"), - pytest.param({"any_of": [{"equals_string": "x"}]}, id="slot-any_of"), + pytest.param({"pattern": "^x"}, "pattern", id="pattern"), + pytest.param({"minimum_value": 0}, "needs a type with a numeric datatype", id="minimum_value"), + pytest.param({"maximum_value": 5}, "needs a type with a numeric datatype", id="maximum_value"), + pytest.param({"recommended": True}, "recommended", id="recommended"), + pytest.param({"minimum_cardinality": 1}, "minimum_cardinality", id="minimum_cardinality"), + pytest.param({"exact_cardinality": 1}, "exact_cardinality", id="exact_cardinality"), + pytest.param({"equals_number": 3}, "equals_number", id="equals_number"), + pytest.param({"any_of": [{"equals_string": "x"}]}, "any_of", id="slot-any_of"), ], ) @pytest.mark.parametrize("condition", ["guard", "target"]) -def test_rule_extra_condition_operator_skipped(caplog, condition, extra): - """A guard or target condition that adds any operator to the ones a pattern - translates makes the rule untranslatable.""" +def test_rule_extra_condition_operator_skipped(caplog, condition, extra, reason): + """A guard or target condition that adds an operator no translation + supports, or a bound on a string slot, makes the rule untranslatable: + dropping it would widen or weaken the rule.""" guard = {"value_presence": "PRESENT", **(extra if condition == "guard" else {})} target = {"equals_string": "x", **(extra if condition == "target" else {})} schema = _single_rule_schema(_rule({"guard": guard}, {"target": target})) - _assert_skipped(caplog, schema, "match none of the translated patterns") + _assert_skipped(caplog, schema, reason) -@pytest.mark.parametrize("presence", ["ABSENT", "UNCOMMITTED"]) -def test_rule_guard_presence_other_than_present_skipped(caplog, presence): - """Only ``value_presence: PRESENT`` is a guard: reading ``ABSENT`` as a - guard would invert the trigger.""" - schema = _single_rule_schema(_rule({"guard": {"value_presence": presence}}, {"target": {"equals_string": "x"}})) - _assert_skipped(caplog, schema, "match none of the translated patterns") +@pytest.mark.parametrize( + "guard_extra,target_extra", + [ + pytest.param({"required": True}, None, id="guard-required"), + pytest.param(None, {"required": True}, id="target-required"), + ], +) +@pytest.mark.parametrize("open_world", [False, True]) +@pytest.mark.parametrize("instance,valid_closed_world,valid_open_world", _RULE_INSTANCES) +def test_rule_extra_operator_translated_exactly( + guard_extra, target_extra, open_world, instance, valid_closed_world, valid_open_world +): + """An extra operator the composed translation supports is translated, not + dropped: a redundant ``required: true`` on the guard changes nothing, and on + the target it requires the target even in an open world.""" + obj = {key: text.format(value="x", other="y") for key, text in instance.items()} + schema = _rule_target_schema( + "string", "x", open_world=open_world, guard_extra=guard_extra, target_extra=target_extra + ) + target_required = bool(target_extra and target_extra.get("required")) + valid = valid_closed_world if target_required or not open_world else valid_open_world + assert len(_sparql_queries(_parse_shacl(schema), EX_RTR.Thing)) == 1 + assert _json_schema_and_shacl_verdicts(schema, "Thing", obj) == (valid, valid) + + +@pytest.mark.parametrize( + "presence,instance,valid", + [ + pytest.param("ABSENT", {"guard": "g", "target": "y"}, True, id="absent-guard-present"), + pytest.param("ABSENT", {"target": "y"}, False, id="absent-other"), + pytest.param("ABSENT", {"target": "x"}, True, id="absent-allowed"), + pytest.param("ABSENT", {}, False, id="absent-target-absent"), + pytest.param("UNCOMMITTED", {"guard": "g", "target": "y"}, False, id="uncommitted-other"), + pytest.param("UNCOMMITTED", {"target": "x"}, True, id="uncommitted-allowed"), + pytest.param("UNCOMMITTED", {}, False, id="uncommitted-target-absent"), + ], +) +def test_rule_guard_presence_other_than_present_composed(presence, instance, valid): + """A guard with ``value_presence: ABSENT`` triggers the rule when the guard + is absent, one with ``UNCOMMITTED`` always; the named pattern, which reads + ``PRESENT``, does not match them, and the composed translation does, as in + JSON Schema.""" + schema = _rule_target_schema("string", "x", guard_extra={"value_presence": presence}) + (query,) = _sparql_queries(_parse_shacl(schema), EX_RTR.Thing) + assert "?guard" not in query, query # composed, not the named pattern + assert _json_schema_and_shacl_verdicts(schema, "Thing", instance) == (valid, valid) @pytest.mark.parametrize("operator", ["any_of", "all_of", "exactly_one_of", "none_of"]) @@ -4485,11 +4531,21 @@ def test_rule_expression_level_operator_skipped(caplog, side, operator): _assert_skipped(caplog, _single_rule_schema(rule), f"its {side} use {operator}") -def test_rule_post_with_both_equals_forms_skipped(caplog): - """equals_string and equals_string_in set together is ambiguous — skip, - do not let one form silently win.""" - schema = _single_rule_schema(_rule(_GUARD_PRESENT, {"target": {"equals_string": "a", "equals_string_in": ["b"]}})) - _assert_skipped(caplog, schema, "match none of the translated patterns") +@pytest.mark.parametrize( + "allowed,target,valid", + [ + pytest.param(["x", "y"], "x", True, id="in-both"), + pytest.param(["x", "y"], "y", False, id="in-one"), + pytest.param(["y"], "x", False, id="contradictory"), + pytest.param(["y"], "y", False, id="contradictory-other"), + ], +) +def test_rule_post_with_both_equals_forms_is_a_conjunction(allowed, target, valid): + """equals_string and equals_string_in set together must both hold, as in + JSON Schema (``const`` and ``enum``); neither form silently wins.""" + schema = _rule_target_schema("string", "x", target_extra={"equals_string_in": allowed}) + assert len(_sparql_queries(_parse_shacl(schema), EX_RTR.Thing)) == 1 + assert _json_schema_and_shacl_verdicts(schema, "Thing", {"guard": "g", "target": target}) == (valid, valid) def test_rule_exclusive_value_extra_operator_skipped(caplog): @@ -4560,3 +4616,929 @@ def test_rule_equals_true_on_string_slot_pyshacl_end_to_end(status, violates): ) _, focus_nodes = _validate_rules(_STRING_TRUE_SCHEMA_YAML, data) assert focus_nodes == ({URIRef("https://example.org/string-true/x")} if violates else set()) + + +# =========================================================================== +# Compositional fallback +# +# A rule no named pattern matches is composed from its operators. Whether a +# condition requires its slot follows the JSON Schema generator: value_presence, +# then required, then a default (preconditions do, postconditions unless +# open_world, inner conditions of a nested expression do not). Each value of a +# slot must satisfy the condition: a slot constraint applies to all members of +# a collection (05validation.md), as the JSON Schema generator's `items` reads +# it; has_member requires some value to satisfy its condition. +# =========================================================================== + +EX_COMP = rdflib.Namespace("https://example.org/compose/") +_COMP_PREFIXES = "@prefix ex: .\n@prefix xsd: .\n" + + +def _compose_schema( + rule: dict, + *, + part_usage: dict | None = None, + thing_usage: dict | None = None, + slots: list[str] | None = None, + subclass: bool = False, +) -> str: + """A schema whose class ``Thing`` has the slots used by the compositional tests and the one *rule*. + + ``Part`` is the inlined class of ``part`` / ``parts`` and of its own + ``sub``; ``SpecialPart`` narrows its ``kind``. ``site`` references a + ``Site`` by its identifier. *part_usage* and + *thing_usage* add ``slot_usage``, *slots* adds schema slots to ``Thing``, + and *subclass* adds ``SubThing``, which inherits the rule. + """ + schema = { + "id": "https://example.org/compose", + "name": "compose", + "prefixes": {"linkml": "https://w3id.org/linkml/", "ex": str(EX_COMP), "xsd": str(XSD)}, + "imports": ["linkml:types"], + "default_prefix": "ex", + "default_range": "string", + "enums": { + "Kind": {"permissible_values": {"Plain": {}, "Special": {"meaning": "ex:Special"}}}, + "SpecialKind": {"permissible_values": {"Special": {"meaning": "ex:VerySpecial"}}}, + }, + "slots": {"kind": {"range": "Kind"}}, + "classes": { + "Part": { + "class_uri": "ex:Part", + "slots": ["kind"], + "attributes": { + "depth": {"range": "integer"}, + "label": {}, + "marks": {"range": "integer", "multivalued": True}, + "sub": {"range": "Part", "inlined": True}, + }, + "slot_usage": part_usage or {}, + }, + "SpecialPart": { + "is_a": "Part", + "class_uri": "ex:SpecialPart", + "slot_usage": {"kind": {"range": "SpecialKind"}}, + }, + "Site": { + "class_uri": "ex:Site", + "attributes": {"id": {"identifier": True, "range": "uriorcurie"}, "depth": {"range": "integer"}}, + }, + "Thing": { + "class_uri": "ex:Thing", + "slots": slots or [], + "attributes": { + "mode": {}, + "flag": {"range": "boolean"}, + "level": {"range": "integer"}, + "levels": {"range": "integer", "multivalued": True}, + "note": {}, + "part": {"range": "Part", "inlined": True}, + "parts": {"range": "Part", "inlined": True, "multivalued": True, "inlined_as_list": True}, + "site": {"range": "Site"}, + }, + "slot_usage": thing_usage or {}, + "rules": [rule], + }, + }, + } + if subclass: + schema["classes"]["SubThing"] = {"is_a": "Thing", "class_uri": "ex:SubThing"} + return json.dumps(schema) + + +_REQUIRE_NOTE = {"note": {"required": True}} +_DEPTH_AT_MOST_ZERO = {"range_expression": {"slot_conditions": {"depth": {"maximum_value": 0}}}} +_IF_MODE_M = {"mode": {"equals_string": "m"}} +_LEVEL_10_TO_20 = {"level": {"minimum_value": 10, "maximum_value": 20}} + + +def _inner(**conditions: dict) -> dict: + """A ``range_expression`` with the slot *conditions* on the slot's class.""" + return {"range_expression": {"slot_conditions": conditions}} + + +@pytest.mark.parametrize("target_class", ["Thing", "SubThing"]) +@pytest.mark.parametrize( + "pre,post,instance,valid", + [ + # equals_string, and presence in the postcondition + pytest.param(_IF_MODE_M, _REQUIRE_NOTE, {"mode": "m"}, False, id="equals-required"), + pytest.param(_IF_MODE_M, _REQUIRE_NOTE, {"mode": "m", "note": "n"}, True, id="equals-met"), + pytest.param(_IF_MODE_M, _REQUIRE_NOTE, {"mode": "x"}, True, id="equals-not-triggered"), + pytest.param(_IF_MODE_M, _REQUIRE_NOTE, {}, True, id="precondition-slot-absent"), + pytest.param(_IF_MODE_M, {"note": {"value_presence": "PRESENT"}}, {"mode": "m"}, False, id="presence-post"), + pytest.param(_IF_MODE_M, {"note": {}}, {"mode": "m"}, False, id="empty-post-required-by-default"), + pytest.param( + _IF_MODE_M, {"note": {"value_presence": "ABSENT"}}, {"mode": "m", "note": "n"}, False, id="absent-post" + ), + pytest.param(_IF_MODE_M, {"note": {"value_presence": "ABSENT"}}, {"mode": "m"}, True, id="absent-met"), + # numeric bounds are inclusive + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 15}, False, id="bounds-inside"), + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 10}, False, id="bounds-at-minimum"), + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 20}, False, id="bounds-at-maximum"), + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 25}, True, id="bounds-above"), + pytest.param(_LEVEL_10_TO_20, _REQUIRE_NOTE, {"level": 5}, True, id="bounds-below"), + # presence in the precondition + pytest.param({"level": {"value_presence": "PRESENT"}}, _REQUIRE_NOTE, {"level": 1}, False, id="presence-pre"), + pytest.param({"mode": {"required": True}}, _REQUIRE_NOTE, {"mode": "m"}, False, id="required-pre"), + pytest.param({"mode": {"required": True}}, _REQUIRE_NOTE, {}, True, id="required-pre-absent"), + pytest.param({"mode": {}}, _REQUIRE_NOTE, {"mode": "x"}, False, id="empty-pre-requires-presence"), + pytest.param({"mode": {}}, _REQUIRE_NOTE, {}, True, id="empty-pre-absent"), + pytest.param({"mode": {"value_presence": "ABSENT"}}, _REQUIRE_NOTE, {}, False, id="absent-pre"), + pytest.param( + {"mode": {"value_presence": "ABSENT"}}, _REQUIRE_NOTE, {"mode": "m"}, True, id="absent-pre-present" + ), + pytest.param( + {"mode": {"required": False, "equals_string": "m"}}, _REQUIRE_NOTE, {}, False, id="optional-pre-absent" + ), + pytest.param( + {"mode": {"required": False, "equals_string": "m"}}, _REQUIRE_NOTE, {"mode": "x"}, True, id="optional-pre" + ), + pytest.param( + {"mode": {"value_presence": "UNCOMMITTED", "equals_string": "m"}}, + _REQUIRE_NOTE, + {}, + False, + id="uncommitted-pre-absent", + ), + pytest.param( + {"mode": {"value_presence": "UNCOMMITTED", "required": True, "equals_string": "m"}}, + _REQUIRE_NOTE, + {}, + False, + id="value-presence-overrides-required", + ), + pytest.param( + {"level": {"value_presence": "PRESENT", "minimum_value": 3}}, + _REQUIRE_NOTE, + {}, + True, + id="presence-bound-absent", + ), + pytest.param( + {"level": {"value_presence": "PRESENT", "minimum_value": 3}}, + _REQUIRE_NOTE, + {"level": 1}, + True, + id="presence-bound-fails", + ), + pytest.param( + {"level": {"value_presence": "PRESENT", "minimum_value": 3}}, + _REQUIRE_NOTE, + {"level": 5}, + False, + id="presence-bound-holds", + ), + # every value of a multivalued slot + pytest.param( + {"levels": {"minimum_value": 10}}, _REQUIRE_NOTE, {"levels": [12, 15]}, False, id="every-value-holds" + ), + pytest.param({"levels": {"minimum_value": 10}}, _REQUIRE_NOTE, {"levels": [12, 3]}, True, id="one-value-fails"), + # a nested range_expression, whose inner conditions hold for an absent inner slot + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE, {"part": {"depth": -1}}, False, id="nested-holds"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE, {"part": {"depth": 5}}, True, id="nested-fails"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE, {"part": {}}, False, id="nested-inner-absent"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE, {}, True, id="nested-container-absent"), + pytest.param( + {"part": {"required": False, **_DEPTH_AT_MOST_ZERO}}, _REQUIRE_NOTE, {}, False, id="optional-container" + ), + pytest.param( + {"part": _inner(depth={"maximum_value": 0, "required": True})}, + _REQUIRE_NOTE, + {"part": {}}, + True, + id="nested-inner-required-absent", + ), + pytest.param( + {"part": _inner(depth={"value_presence": "PRESENT"})}, + _REQUIRE_NOTE, + {"part": {}}, + True, + id="nested-inner-present-absent", + ), + pytest.param( + {"part": _inner(depth={"value_presence": "PRESENT"})}, + _REQUIRE_NOTE, + {"part": {"depth": 1}}, + False, + id="nested-inner-present", + ), + pytest.param( + {"part": _inner(depth={"value_presence": "ABSENT"})}, + _REQUIRE_NOTE, + {"part": {}}, + False, + id="nested-inner-absent-holds", + ), + pytest.param( + {"part": _inner(depth={"value_presence": "ABSENT"})}, + _REQUIRE_NOTE, + {"part": {"depth": 1}}, + True, + id="nested-inner-absent-fails", + ), + pytest.param( + {"part": _inner(depth={"minimum_value": -5, "maximum_value": 0})}, + _REQUIRE_NOTE, + {"part": {"depth": 3}}, + True, + id="nested-bounds-above", + ), + pytest.param( + {"part": _inner(sub=_DEPTH_AT_MOST_ZERO)}, + _REQUIRE_NOTE, + {"part": {"sub": {"depth": -1}}}, + False, + id="two-hops", + ), + pytest.param( + {"part": _inner(sub=_DEPTH_AT_MOST_ZERO)}, + _REQUIRE_NOTE, + {"part": {"sub": {"depth": 5}}}, + True, + id="two-hops-fails", + ), + pytest.param( + {"parts": _DEPTH_AT_MOST_ZERO}, + _REQUIRE_NOTE, + {"parts": [{"depth": -1}, {"depth": -2}]}, + False, + id="every-member", + ), + pytest.param( + {"parts": _DEPTH_AT_MOST_ZERO}, + _REQUIRE_NOTE, + {"parts": [{"depth": -1}, {"depth": 5}]}, + True, + id="one-member-fails", + ), + # value operators in a postcondition apply to every value of the slot, which is required + pytest.param( + _IF_MODE_M, {"note": {"equals_string": "n"}}, {"mode": "m", "note": "n"}, True, id="post-equals-met" + ), + pytest.param( + _IF_MODE_M, {"note": {"equals_string": "n"}}, {"mode": "m", "note": "x"}, False, id="post-equals-other" + ), + pytest.param(_IF_MODE_M, {"note": {"equals_string": "n"}}, {"mode": "m"}, False, id="post-equals-absent"), + pytest.param( + _IF_MODE_M, {"note": {"equals_string_in": ["n", "o"]}}, {"mode": "m", "note": "o"}, True, id="post-in-met" + ), + pytest.param( + _IF_MODE_M, + {"note": {"equals_string_in": ["n", "o"]}}, + {"mode": "m", "note": "x"}, + False, + id="post-in-other", + ), + pytest.param(_IF_MODE_M, {"level": {"minimum_value": 3}}, {"mode": "m", "level": 3}, True, id="post-bound-met"), + pytest.param( + _IF_MODE_M, {"level": {"minimum_value": 3}}, {"mode": "m", "level": 2}, False, id="post-bound-fails" + ), + pytest.param( + _IF_MODE_M, {"levels": {"maximum_value": 5}}, {"mode": "m", "levels": [1, 5]}, True, id="post-every-value" + ), + pytest.param( + _IF_MODE_M, {"levels": {"maximum_value": 5}}, {"mode": "m", "levels": [1, 9]}, False, id="post-one-fails" + ), + pytest.param( + _IF_MODE_M, {"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m", "part": {"depth": 0}}, True, id="post-nested-met" + ), + pytest.param( + _IF_MODE_M, + {"part": _DEPTH_AT_MOST_ZERO}, + {"mode": "m", "part": {"depth": 1}}, + False, + id="post-nested-fails", + ), + pytest.param(_IF_MODE_M, {"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m"}, False, id="post-nested-absent"), + # equals_string_in in a precondition and an inner condition + pytest.param({"mode": {"equals_string_in": ["m", "n"]}}, _REQUIRE_NOTE, {"mode": "n"}, False, id="pre-in"), + pytest.param({"mode": {"equals_string_in": ["m", "n"]}}, _REQUIRE_NOTE, {"mode": "x"}, True, id="pre-in-other"), + pytest.param( + {"part": _inner(label={"equals_string_in": ["a", "b"]})}, + _REQUIRE_NOTE, + {"part": {"label": "b"}}, + False, + id="inner-in", + ), + # several preconditions are a conjunction + pytest.param( + {**_IF_MODE_M, "level": {"minimum_value": 10}}, + _REQUIRE_NOTE, + {"mode": "m", "level": 12}, + False, + id="two-preconditions", + ), + pytest.param( + {**_IF_MODE_M, "level": {"minimum_value": 10}}, + _REQUIRE_NOTE, + {"mode": "m", "level": 3}, + True, + id="two-preconditions-one-fails", + ), + ], +) +def test_compose_agrees_with_json_schema(pre, post, instance, valid, target_class): + """A composed rule decides each instance as the JSON Schema generator's + if/then does, in the class declaring it and in a subclass.""" + schema = _compose_schema(_rule(pre, post), subclass=True) + assert len(_sparql_queries(_parse_shacl(schema), EX_COMP[target_class])) == 1 + assert _json_schema_and_shacl_verdicts(schema, target_class, instance) == (valid, valid) + + +@pytest.mark.parametrize( + "post,instance,valid", + [ + pytest.param(_REQUIRE_NOTE, {"mode": "m"}, False, id="required-absent"), + pytest.param(_REQUIRE_NOTE, {"mode": "m", "note": "n"}, True, id="required-met"), + pytest.param({"note": {"value_presence": "PRESENT"}}, {"mode": "m"}, False, id="present-absent"), + pytest.param({"note": {"value_presence": "PRESENT"}}, {"mode": "m", "note": "n"}, True, id="present-met"), + pytest.param({"note": {"value_presence": "ABSENT"}}, {"mode": "m", "note": "n"}, False, id="absent-present"), + pytest.param({"note": {"value_presence": "ABSENT"}}, {"mode": "m"}, True, id="absent-met"), + pytest.param({"note": {"equals_string": "n"}}, {"mode": "m"}, True, id="value-omitted"), + pytest.param({"note": {"equals_string": "n"}}, {"mode": "m", "note": "x"}, False, id="value-other"), + ], +) +@pytest.mark.parametrize("target_class", ["Thing", "SubThing"]) +def test_compose_open_world(post, instance, valid, target_class): + """With ``open_world`` a postcondition slot may be omitted unless the + postcondition states its presence, as in JSON Schema; a value present must + still satisfy it.""" + schema = _compose_schema(_rule(_IF_MODE_M, post, open_world=True), subclass=True) + assert _json_schema_and_shacl_verdicts(schema, target_class, instance) == (valid, valid) + + +_SPECIAL_KIND = {"kind": {"equals_string": "Special"}} + + +@pytest.mark.parametrize( + "member,post_presence,open_world,members,violates", + [ + pytest.param(_SPECIAL_KIND, {}, False, "ex:p1 . ex:p1 ex:kind ex:Special", False, id="matching-member"), + pytest.param(_SPECIAL_KIND, {}, False, 'ex:p1 . ex:p1 ex:kind "Plain"', True, id="no-matching-member"), + pytest.param( + _SPECIAL_KIND, + {}, + False, + 'ex:p1, ex:p2 . ex:p1 ex:kind "Plain" . ex:p2 ex:kind ex:Special', + False, + id="one-matching", + ), + pytest.param(_SPECIAL_KIND, {}, False, 'ex:p1 . ex:p1 ex:label "l"', False, id="member-without-kind"), + pytest.param( + {"kind": {"equals_string": "Special", "required": True}}, + {}, + False, + 'ex:p1 . ex:p1 ex:label "l"', + True, + id="member-without-required-kind", + ), + pytest.param( + {"kind": {"value_presence": "ABSENT"}}, + {}, + False, + "ex:p1 . ex:p1 ex:kind ex:Special", + True, + id="inner-absent", + ), + pytest.param( + {"kind": {"value_presence": "ABSENT"}}, + {}, + False, + 'ex:p1 . ex:p1 ex:label "l"', + False, + id="inner-absent-met", + ), + pytest.param( + {**_SPECIAL_KIND, "label": {"equals_string": "l"}}, + {}, + False, + 'ex:p1 . ex:p1 ex:kind ex:Special ; ex:label "x"', + True, + id="two-inner-conditions-one-fails", + ), + pytest.param( + {**_SPECIAL_KIND, "label": {"equals_string": "l"}}, + {}, + False, + 'ex:p1 . ex:p1 ex:kind ex:Special ; ex:label "l"', + False, + id="two-inner-conditions-hold", + ), + pytest.param(_SPECIAL_KIND, {}, False, None, True, id="no-members"), + pytest.param(_SPECIAL_KIND, {}, True, None, False, id="no-members-open-world"), + pytest.param(_SPECIAL_KIND, {"required": True}, True, None, True, id="no-members-open-world-required"), + pytest.param(_SPECIAL_KIND, {"required": False}, False, None, False, id="no-members-not-required"), + pytest.param( + _SPECIAL_KIND, + {"value_presence": "UNCOMMITTED", "required": True}, + False, + None, + False, + id="value-presence-overrides-required", + ), + pytest.param(_SPECIAL_KIND, {"value_presence": "ABSENT"}, False, None, False, id="absent-slot"), + pytest.param( + _SPECIAL_KIND, + {"value_presence": "ABSENT"}, + False, + "ex:p1 . ex:p1 ex:kind ex:Special", + True, + id="absent-slot-present", + ), + pytest.param( + {"sub": _DEPTH_AT_MOST_ZERO}, + {}, + False, + "ex:p1 . ex:p1 ex:sub ex:s1 . ex:s1 ex:depth 0", + False, + id="two-hops", + ), + pytest.param( + {"sub": _DEPTH_AT_MOST_ZERO}, + {}, + False, + "ex:p1 . ex:p1 ex:sub ex:s1 . ex:s1 ex:depth 5", + True, + id="two-hops-fails", + ), + pytest.param( + _SPECIAL_KIND, {}, True, 'ex:p1 . ex:p1 ex:kind "Plain"', True, id="no-matching-member-open-world" + ), + ], +) +@pytest.mark.parametrize("target_class", ["Thing", "SubThing"]) +def test_compose_has_member(member, post_presence, open_world, members, violates, target_class): + """``has_member`` is violated when no member satisfies the member condition. + + A member condition holds for an absent inner slot unless it requires the + slot. Whether ``parts`` must be present is decided as for any + postcondition (``open_world``, ``required``, ``value_presence``). The JSON + Schema generator drops ``has_member`` inside rule conditions, so the + verdicts are checked against explicit expectations. + """ + post = {"parts": {"has_member": _inner(**member), **post_presence}} + schema = _compose_schema(_rule({"mode": {"value_presence": "PRESENT"}}, post, open_world=open_world), subclass=True) + body = f'ex:x a ex:{target_class} ; ex:mode "m"' + ("" if members is None else f" ; ex:parts {members}") + _, focus_nodes = _validate_rules(schema, f"{_COMP_PREFIXES}{body} .") + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +_SOME_LEVEL_10 = {"levels": {"has_member": {"minimum_value": 10}}} +_SOME_LEVEL_10_ALL_20 = {"levels": {"has_member": {"minimum_value": 10}, "maximum_value": 20}} +_PART_WITH_SOME_MARK_10 = {"part": _inner(marks={"has_member": {"minimum_value": 10}})} + + +@pytest.mark.parametrize( + "pre,post,data,violates", + [ + pytest.param(_IF_MODE_M, _SOME_LEVEL_10, " ; ex:levels 3, 12", False, id="post-some"), + pytest.param(_IF_MODE_M, _SOME_LEVEL_10, " ; ex:levels 3, 4", True, id="post-none"), + pytest.param(_IF_MODE_M, _SOME_LEVEL_10, "", True, id="post-absent"), + pytest.param(_SOME_LEVEL_10, _REQUIRE_NOTE, " ; ex:levels 3, 12", True, id="pre-some"), + pytest.param(_SOME_LEVEL_10, _REQUIRE_NOTE, " ; ex:levels 3, 4", False, id="pre-none"), + pytest.param(_SOME_LEVEL_10, _REQUIRE_NOTE, "", False, id="pre-absent"), + # with value operators on the same slot, which every value must satisfy + pytest.param(_IF_MODE_M, _SOME_LEVEL_10_ALL_20, " ; ex:levels 12, 15", False, id="post-some-and-all"), + pytest.param(_IF_MODE_M, _SOME_LEVEL_10_ALL_20, " ; ex:levels 12, 25", True, id="post-some-not-all"), + pytest.param(_IF_MODE_M, _SOME_LEVEL_10_ALL_20, " ; ex:levels 3, 4", True, id="post-all-not-some"), + # inside a nested condition, on the values of the inner slot + pytest.param( + _PART_WITH_SOME_MARK_10, _REQUIRE_NOTE, " ; ex:part ex:p1 . ex:p1 ex:marks 3, 12", True, id="inner-some" + ), + pytest.param( + _PART_WITH_SOME_MARK_10, _REQUIRE_NOTE, " ; ex:part ex:p1 . ex:p1 ex:marks 3, 4", False, id="inner-none" + ), + pytest.param( + _PART_WITH_SOME_MARK_10, _REQUIRE_NOTE, ' ; ex:part ex:p1 . ex:p1 ex:label "l"', True, id="inner-absent" + ), + pytest.param( + {**_PART_WITH_SOME_MARK_10, "levels": {"minimum_value": 0}}, + _REQUIRE_NOTE, + " ; ex:levels 1 ; ex:part ex:p1 . ex:p1 ex:marks 4 . ex:y a ex:Thing ; ex:part ex:p2 . ex:p2 ex:marks 12", + False, + id="inner-member-of-another-subject", + ), + ], +) +def test_compose_has_member_with_value_operators(pre, post, data, violates): + """``has_member`` holds when some value satisfies its value operators, in a + postcondition, a precondition and a nested condition alike, next to value + operators that every value must satisfy. An absent slot fails a + precondition, which requires its slot, violates a closed-world + postcondition, and satisfies a nested condition, which does not.""" + schema = _compose_schema(_rule(pre, post)) + _, focus_nodes = _validate_rules(schema, f'{_COMP_PREFIXES}ex:x a ex:Thing ; ex:mode "m"{data} .') + assert EX_COMP.x in focus_nodes if violates else EX_COMP.x not in focus_nodes + + +@pytest.mark.parametrize( + "rule,data,expected", + [ + pytest.param( + _rule(_IF_MODE_M, _REQUIRE_NOTE), + 'ex:x a ex:Thing ; ex:mode "m" .', + [(EX_COMP.x, EX_COMP.note, EX_COMP.x)], + id="required", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"value_presence": "ABSENT"}}), + 'ex:x a ex:Thing ; ex:mode "m" ; ex:note "n" .', + [(EX_COMP.x, EX_COMP.note, Literal("n"))], + id="absent", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"equals_string": "n"}}), + 'ex:x a ex:Thing ; ex:mode "m" ; ex:note "a", "n", "b" .', + [(EX_COMP.x, EX_COMP.note, Literal("a")), (EX_COMP.x, EX_COMP.note, Literal("b"))], + id="each-offending-value", + ), + pytest.param( + _rule(_IF_MODE_M, {"parts": _inner(label={"equals_string": "l"}, depth={"maximum_value": 0})}), + 'ex:x a ex:Thing ; ex:mode "m" ; ex:parts ex:p1 . ex:p1 ex:label "x" ; ex:depth 5 .', + [(EX_COMP.x, EX_COMP.parts, EX_COMP.p1)], + id="value-violating-twice-reported-once", + ), + pytest.param( + _rule(_IF_MODE_M, _SOME_LEVEL_10), + 'ex:x a ex:Thing ; ex:mode "m" ; ex:levels 3, 4 .', + [(EX_COMP.x, EX_COMP.levels, EX_COMP.x)], + id="no-member-satisfies", + ), + ], +) +def test_compose_result_names_path_and_value(rule, data, expected): + """A composed result names the postcondition's property and the offending + value, once per value, or the focus node when a value is missing (SHACL + §5.3.2).""" + _, results = _rule_results(_compose_schema(rule), _COMP_PREFIXES + data) + assert sorted(results) == sorted(expected) + + +def test_compose_query_yields_one_solution_per_offending_value(): + """A value that violates two parts of the postcondition is one solution, + so a processor that maps every solution to a result (SHACL §5.3.2) reports + it once.""" + rule = _rule(_IF_MODE_M, {"parts": _inner(label={"equals_string": "l"}, depth={"maximum_value": 0})}) + (query,) = _sparql_queries(_parse_shacl(_compose_schema(rule)), EX_COMP.Thing) + data = rdflib.Graph().parse( + data=_COMP_PREFIXES + 'ex:x a ex:Thing ; ex:mode "m" ; ex:parts ex:p1 . ex:p1 ex:label "x" ; ex:depth 5 .', + format="turtle", + ) + solutions = [(row.path, row.value) for row in data.query(query, initBindings={"this": EX_COMP.x})] + assert solutions == [(EX_COMP.parts, EX_COMP.p1)] + + +@pytest.mark.parametrize( + "flag,violates", + [ + pytest.param(Literal("true", datatype=XSD.boolean, normalize=False), True, id="true"), + pytest.param(Literal("1", datatype=XSD.boolean, normalize=False), True, id="lexical-1"), + pytest.param(Literal("false", datatype=XSD.boolean, normalize=False), False, id="false"), + pytest.param(Literal("true"), False, id="string-true"), + ], +) +def test_compose_boolean_precondition_compared_by_value(flag, violates): + """``equals_string`` on a boolean precondition slot compares the boolean it denotes, as in the named patterns.""" + schema = _compose_schema(_rule({"flag": {"equals_string": "true"}}, _REQUIRE_NOTE)) + data = rdflib.Graph() + data.add((EX_COMP.x, RDF.type, EX_COMP.Thing)) + data.add((EX_COMP.x, EX_COMP.flag, flag)) + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +# The numeric datatypes of SPARQL 1.1 §17.1, . +_SPARQL_NUMERIC_DATATYPES = [ + *("integer", "decimal", "float", "double"), + *("nonPositiveInteger", "negativeInteger", "long", "int", "short", "byte"), + *("nonNegativeInteger", "unsignedLong", "unsignedInt", "unsignedShort", "unsignedByte", "positiveInteger"), +] + + +@pytest.mark.parametrize("datatype", _SPARQL_NUMERIC_DATATYPES) +def test_compose_bounds_on_every_sparql_numeric_datatype(datatype): + """Bounds are translated on a type with any numeric datatype of SPARQL 1.1 + §17.1 and compare its values numerically.""" + schema = json.loads(_compose_schema(_rule({"amount": {"minimum_value": -10, "maximum_value": 10}}, _REQUIRE_NOTE))) + base = datatype if datatype in ("decimal", "float", "double") else "integer" + schema["types"] = {"Amount": {"typeof": base, "uri": f"xsd:{datatype}"}} + schema["classes"]["Thing"]["attributes"]["amount"] = {"range": "Amount"} + sign = -1 if datatype in ("nonPositiveInteger", "negativeInteger") else 1 + for value, violates in ((5 * sign, True), (50 * sign, False)): + data = rdflib.Graph() + data.add((EX_COMP.x, RDF.type, EX_COMP.Thing)) + data.add((EX_COMP.x, EX_COMP.amount, Literal(str(value), datatype=XSD[datatype]))) + _, focus_nodes = _validate_rules(json.dumps(schema), data) + assert focus_nodes == ({EX_COMP.x} if violates else set()), value + + +@pytest.mark.parametrize( + "slot_range,translated", + [ + *(pytest.param(r, True, id=r) for r in ("integer", "decimal", "float", "double")), + pytest.param("Count", True, id="custom-nonNegativeInteger"), + *( + pytest.param(r, False, id=r) + for r in ("string", "date", "datetime", "time", "boolean", "uriorcurie", "Kind", "Part") + ), + pytest.param("Year", False, id="custom-gYear"), + pytest.param("Span", False, id="custom-duration"), + ], +) +def test_compose_bounds_only_on_numeric_ranges(caplog, slot_range, translated): + """Bounds compare numerically, so they are translated on a type with a + numeric datatype and skip the rule on any other range: SPARQL orders no + string, date, duration, boolean, IRI or node against a number (§17.3).""" + schema = json.loads(_compose_schema(_rule({"amount": {"minimum_value": 1}}, _REQUIRE_NOTE))) + schema["types"] = { + "Count": {"typeof": "integer", "uri": "xsd:nonNegativeInteger"}, + "Year": {"typeof": "string", "uri": "xsd:gYear"}, + "Span": {"typeof": "string", "uri": "xsd:duration"}, + } + schema["classes"]["Thing"]["attributes"]["amount"] = {"range": slot_range} + with caplog.at_level(logging.WARNING): + g = _parse_shacl(json.dumps(schema)) + assert len(_sparql_queries(g, EX_COMP.Thing)) == (1 if translated else 0) + skipped = "its minimum_value on slot 'amount' needs a type with a numeric datatype, and the slot's range is " + skipped += repr(slot_range) + assert any(skipped in rec.message for rec in caplog.records) != translated, caplog.text + + +@pytest.mark.parametrize( + "level,violates", + [ + pytest.param(Literal(15), True, id="integer"), + pytest.param(Literal("15"), False, id="string"), + pytest.param(EX_COMP.fifteen, False, id="iri"), + ], +) +def test_compose_bound_fails_on_a_value_that_is_not_a_number(level, violates): + """A value that is not a number fails a numeric bound, since comparing it + is a type error (SPARQL 1.1 §17.3); its datatype violation is reported + by the property shape, not by the rule.""" + schema = _compose_schema(_rule({"level": {"minimum_value": 10}}, _REQUIRE_NOTE)) + data = rdflib.Graph() + data.add((EX_COMP.x, RDF.type, EX_COMP.Thing)) + data.add((EX_COMP.x, EX_COMP.level, level)) + _, focus_nodes = _validate_rules(schema, data) + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +@pytest.mark.parametrize( + "rule,reason", + [ + pytest.param( + _rule({"level": {"equals_string": "3"}}, _REQUIRE_NOTE), + "whose range 'integer' is neither an enum nor a type with datatype xsd:string or xsd:boolean", + id="equals-string-on-integer", + ), + pytest.param( + _rule({"part": _inner(label={"maximum_value": 3})}, _REQUIRE_NOTE), + "its maximum_value on slot 'label' needs a type with a numeric datatype, and the slot's range is 'string'", + id="inner-bound-on-string", + ), + pytest.param( + _rule({"mode": {"pattern": "^m"}}, _REQUIRE_NOTE), + "its precondition on slot 'mode' uses pattern", + id="pre-op", + ), + pytest.param( + {"preconditions": {"slot_conditions": {}}, "postconditions": {"slot_conditions": _REQUIRE_NOTE}}, + "its preconditions constrain no slot", + id="empty-preconditions", + ), + pytest.param( + {"preconditions": {"slot_conditions": _IF_MODE_M}, "postconditions": {}}, + "its postconditions constrain no slot", + id="empty-postconditions", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"required": True, "pattern": "^n"}}), + "its postcondition on slot 'note' uses pattern, required", + id="post-op", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"required": False}}), + "its postcondition on slot 'note' constrains nothing", + id="post-not-required", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {}}, open_world=True), + "its postcondition on slot 'note' constrains nothing", + id="post-open-world-empty", + ), + pytest.param( + _rule(_IF_MODE_M, {"note": {"value_presence": "UNCOMMITTED", "required": True}}), + "its postcondition on slot 'note' constrains nothing", + id="post-value-presence-overrides-required", + ), + pytest.param( + _rule(_IF_MODE_M, {**_REQUIRE_NOTE, "level": {"required": True}}), + "its postconditions constrain more than one slot", + id="two-postconditions", + ), + pytest.param( + _rule(_IF_MODE_M, {"parts": {"has_member": {"equals_string": "x"}}}), + "equals_string(_in) on slot 'parts', whose range 'Part' is neither an enum", + id="has-member-value-on-class-range", + ), + pytest.param( + _rule(_IF_MODE_M, {"levels": {"has_member": {"pattern": "^1"}}}), + "its has_member on slot 'levels' uses pattern", + id="has-member-op", + ), + pytest.param( + _rule(_IF_MODE_M, {"levels": {"has_member": {"required": True}}}), + "its has_member on slot 'levels' uses required", + id="has-member-presence", + ), + pytest.param( + _rule(_IF_MODE_M, {"levels": {"has_member": {}}}), + "its has_member on slot 'levels' uses no operator", + id="has-member-empty", + ), + pytest.param( + _rule({"level": _DEPTH_AT_MOST_ZERO}, _REQUIRE_NOTE), + "its range_expression is on slot 'level', whose range is not a class", + id="range-expression-on-non-class", + ), + pytest.param( + _rule( + {"part": {"range_expression": {"any_of": [{"slot_conditions": {"depth": {"maximum_value": 0}}}]}}}, + _REQUIRE_NOTE, + ), + "its range_expression on slot 'part' uses any_of,", + id="range-expression-any-of", + ), + pytest.param( + _rule( + { + "part": { + "range_expression": { + **_DEPTH_AT_MOST_ZERO["range_expression"], + "none_of": [{"slot_conditions": {"label": {"equals_string": "x"}}}], + } + } + }, + _REQUIRE_NOTE, + ), + "its range_expression on slot 'part' uses none_of, slot_conditions", + id="range-expression-slot-conditions-and-none-of", + ), + pytest.param( + _rule({"part": {"range_expression": {"slot_conditions": {}}}}, _REQUIRE_NOTE), + "its range_expression on slot 'part' uses no operator", + id="range-expression-empty", + ), + pytest.param( + _rule({"part": _inner(depth={"pattern": "^1"})}, _REQUIRE_NOTE), + "its inner condition on slot 'depth' uses pattern", + id="inner-op", + ), + pytest.param( + _rule({"part": _inner(nope={"maximum_value": 0})}, _REQUIRE_NOTE), + "'nope', which is not a slot", + id="inner-unknown-slot", + ), + ], +) +def test_compose_untranslatable_rule_skipped(caplog, rule, reason): + """The compositional fallback is exact too: any operator it does not translate skips the rule, with the reason.""" + with caplog.at_level(logging.WARNING): + g = _parse_shacl(_compose_schema(rule)) + assert _sparql_queries(g, EX_COMP.Thing) == [] + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1 and "skipped, because" in messages[0] and reason in messages[0], caplog.text + assert "in the shapes of" not in messages[0], "a problem with an inner slot belongs to the outer rule" + + +@pytest.mark.parametrize("bound", ['"abc"', "2020-01-01", "true", ".nan", ".inf", "1e20"]) +def test_compose_non_numeric_bound_skipped(caplog, bound): + """A ``minimum_value`` that is not a finite number (its metamodel range is + ``Anything``) skips the rule: interpolated, it would make the query + unparsable or compare as arithmetic. YAML 1.1 reads ``1e20``, which has + no dot, as a string.""" + marker = "__BOUND__" + schema = _compose_schema(_rule({"level": {"minimum_value": marker}}, _REQUIRE_NOTE)) + schema = schema.replace(f'"{marker}"', bound) # a raw YAML scalar + with caplog.at_level(logging.WARNING): + g = _parse_shacl(schema) + assert _sparql_queries(g, EX_COMP.Thing) == [] + assert any("is not a finite number" in rec.message for rec in caplog.records), caplog.text + + +@pytest.mark.parametrize( + "schema,reason", + [ + pytest.param( + _compose_schema( + _rule({"part": _inner(kind={"equals_string": "Bogus"}), "mode": {"pattern": "^m"}}, _REQUIRE_NOTE) + ), + "its precondition on slot 'mode' uses pattern", + id="composed", + ), + pytest.param( + _single_rule_schema( + _rule({"levels": {"equals_string": "3"}}, {"levels": {"maximum_cardinality": 1}}), + {"levels": {"range": "integer", "multivalued": True}}, + ), + "whose range 'integer' is neither an enum", + id="exclusive-value", + ), + ], +) +def test_rule_skip_replaces_problems_noted_while_translating(caplog, schema, reason): + """A rule skipped part-way through its translation reports only why it was + skipped: a problem noted earlier (a value no enum permits, the reading of a + bare equals_string) describes a constraint that is not emitted.""" + with caplog.at_level(logging.WARNING): + _parse_shacl(schema) + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 1 and "skipped, because" in messages[0] and reason in messages[0], messages + + +def test_rule_skip_in_a_subclass_keeps_the_problems_of_other_shapes(caplog): + """A rule skipped for a subclass, whose ``slot_usage`` changes a slot, keeps + the problems noted where it is translated.""" + schema = json.loads( + _compose_schema(_rule({"kind": {"equals_string": "Bogus"}}, _REQUIRE_NOTE), slots=["kind"], subclass=True) + ) + schema["classes"]["SubThing"]["slot_usage"] = {"kind": {"range": "integer"}} + with caplog.at_level(logging.WARNING): + g = _parse_shacl(json.dumps(schema)) + assert len(_sparql_queries(g, EX_COMP.Thing)) == 1 and _sparql_queries(g, EX_COMP.SubThing) == [] + messages = [rec.message for rec in caplog.records if "Rule 1 of class 'Thing'" in rec.message] + assert len(messages) == 2, messages + assert any("'Bogus', which is not a permissible value" in m and "in the shapes of" not in m for m in messages) + assert any("skipped, because" in m and "(in the shapes of 'SubThing')" in m for m in messages) + + +@pytest.mark.parametrize( + "part_usage,thing_usage,expected_iris,unexpected_iris", + [ + pytest.param( + {"kind": {"slot_uri": "ex:partKind"}}, {}, [EX_COMP.partKind], [EX_COMP.kind], id="inner-slot-uri-override" + ), + pytest.param( + {"kind": {"slot_uri": "ex:partKind"}}, + {"kind": {"slot_uri": "ex:thingKind"}}, + [EX_COMP.partKind], + [EX_COMP.thingKind], + id="inner-slot-not-shadowed-by-outer", + ), + pytest.param( + {}, {"part": {"range": "SpecialPart"}}, [EX_COMP.VerySpecial], [EX_COMP.Special], id="container-narrowed" + ), + ], +) +def test_compose_inner_slot_resolved_on_range_class(part_usage, thing_usage, expected_iris, unexpected_iris): + """Inner slots of a nested expression resolve in the induced context of the + container slot's range class: their IRI and their enum values come from it.""" + rule = _rule({"part": _inner(kind={"equals_string": "Special"})}, _REQUIRE_NOTE) + schema = _compose_schema( + rule, part_usage=part_usage, thing_usage=thing_usage, slots=["kind"] if "kind" in thing_usage else None + ) + (query,) = _sparql_queries(_parse_shacl(schema), EX_COMP.Thing) + for iri in expected_iris: + assert f"<{iri}>" in query, query + for iri in unexpected_iris: + assert f"<{iri}>" not in query, query + + +@pytest.mark.parametrize( + "values,violates", + [ + pytest.param("", True, id="both-absent"), + pytest.param(' ; ex:mode "m"', False, id="one-present"), + pytest.param(' ; ex:mode "m" ; ex:level 1', False, id="both-present"), + ], +) +def test_compose_several_absent_preconditions_each_hold(values, violates): + """Preconditions are a conjunction, so ``value_presence: ABSENT`` on two + slots requires both to be absent. The JSON Schema generator merges them + into one ``not: {required: [...]}``, "not all present", so the + expectations are explicit.""" + rule = _rule({"mode": {"value_presence": "ABSENT"}, "level": {"value_presence": "ABSENT"}}, _REQUIRE_NOTE) + _, focus_nodes = _validate_rules(_compose_schema(rule), f"{_COMP_PREFIXES}ex:x a ex:Thing{values} .") + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +@pytest.mark.parametrize( + "inner,site,violates", + [ + pytest.param({"maximum_value": 0}, "ex:s1 ex:depth -1 .", True, id="referenced-node-satisfies"), + pytest.param({"maximum_value": 0}, "ex:s1 ex:depth 5 .", False, id="referenced-node-fails"), + pytest.param({"maximum_value": 0}, "", True, id="referenced-node-not-described"), + pytest.param({"maximum_value": 0, "required": True}, "", False, id="required-on-undescribed-node"), + ], +) +def test_compose_range_expression_on_a_reference(inner, site, violates): + """On a slot that references a node instead of inlining it, a nested + condition applies to the referenced node's triples in the data graph; a + node the graph does not describe has none. JSON has only the identifier + there, so the expectations are explicit.""" + rule = _rule({"site": _inner(depth=inner)}, _REQUIRE_NOTE) + data = f"{_COMP_PREFIXES}ex:x a ex:Thing ; ex:site ex:s1 . {site}" + _, focus_nodes = _validate_rules(_compose_schema(rule), data) + assert focus_nodes == ({EX_COMP.x} if violates else set()) From 9918427cd07c9c68805e137c5956dc06a2d40ce6 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 11 Sep 2026 14:48:35 +0200 Subject: [PATCH 18/30] docs(shacl): document rule-to-SHACL-SPARQL constraint generation The SHACL generator translates LinkML rules into sh:sparql constraints, but its documentation did not mention them. Describe the two named patterns and the composed translation with the operators it supports, the semantics shared with the JSON Schema generator (presence, every value versus has_member, value comparison, numeric bounds), inheritance, open_world, --no-emit-rules and the skip warnings, with an example generated by the generator. Replace the dangling "See above for implementation status" in the rules section of advanced.md with links to the JSON Schema and SHACL generator pages. --- docs/generators/shacl.rst | 97 +++++++++++++++++++++++++++++++++++++++ docs/schemas/advanced.md | 2 +- 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/docs/generators/shacl.rst b/docs/generators/shacl.rst index 3e88f0090f..4b4b35a7ad 100644 --- a/docs/generators/shacl.rst +++ b/docs/generators/shacl.rst @@ -84,6 +84,103 @@ Example Output: shacl:targetClass . +Rule constraints (SHACL-SPARQL) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +LinkML `rules `_ state conditional +constraints ("if slot A holds X, slot B must ..."), which property shapes +cannot express. The generator translates each rule into a +`SHACL-SPARQL constraint `_ +(``sh:sparql``) on the node shape of its class, and of every subclass, since +a rule applies to all members of its class. ``--no-emit-rules`` turns this +off. + +The constraint's query selects each focus node that satisfies the +preconditions and violates the postconditions. ``$this`` is the focus node +(`SHACL §5.3.1 `_), +and each result names the postcondition's property and, where there is one, +the offending value (``sh:resultPath``, ``sh:value``). + +Two patterns are recognised first: + +* **Presence implies value**: precondition ``value_presence: PRESENT`` on a + guard slot, and postcondition ``equals_string`` or ``equals_string_in`` on a + target slot. +* **Exclusive value**: precondition ``has_member: {equals_string: V}``, and + postcondition ``maximum_cardinality`` on the same slot. The older form + with a bare ``equals_string: V`` precondition is read the same way, with a + warning. + +Any other rule with one postcondition slot is composed from the operators +its conditions use: ``value_presence``, ``required``, ``equals_string``, +``equals_string_in``, ``minimum_value``, ``maximum_value``, +``range_expression`` (slot conditions on the slot's class) and +``has_member``. + +All translations follow the JSON Schema generator's ``if`` / ``then``: + +* **Presence:** ``value_presence`` decides, then ``required``. Otherwise a + precondition requires its slot, a postcondition requires its slot unless + the rule is ``open_world``, and a condition inside a ``range_expression`` + or ``has_member`` does not. +* **Multivalued slots:** a condition holds when *every* value satisfies it. + ``has_member`` holds when *some* value does. +* **Values:** ``equals_string`` and ``equals_string_in`` are compared as + strings on enum and ``xsd:string`` slots, where an enum value with a + ``meaning`` is compared as that IRI. On ``xsd:boolean`` slots they are + compared as booleans and must be ``true``, ``false``, ``1`` or ``0``. + ``minimum_value`` and ``maximum_value`` are inclusive bounds, translated + only on slots whose datatype SPARQL compares as a number (``xsd:integer``, + ``decimal``, ``float``, ``double`` and the types derived from them). + +A rule outside these forms is skipped with a warning that names the rule and +the reason. It is never partially translated. ``deactivated`` rules are +ignored and ``bidirectional`` rules are skipped. For a rule with +``elseconditions``, only the if/then direction is emitted, and a warning +says so. + +Example: + +.. code-block:: yaml + + classes: + Document: + attributes: + review_score: + range: integer + status: + range: Status # an enum: draft, approved, published + rules: + - description: A document scoring 4 or more must be approved or published. + preconditions: + slot_conditions: + review_score: + minimum_value: 4 + postconditions: + slot_conditions: + status: + equals_string_in: [approved, published] + +generates (abridged): + +.. code-block:: turtle + + ex:Document a sh:NodeShape ; + sh:sparql [ a sh:SPARQLConstraint ; + sh:message "A document scoring 4 or more must be approved or published." ; + sh:select """SELECT DISTINCT $this ( AS ?path) ?value WHERE { + FILTER EXISTS { $this ?pre0 . } + FILTER NOT EXISTS { $this ?pre0 . FILTER ( !( COALESCE( isNumeric( ?pre0 ) && ?pre0 >= 4, false ) ) ) } + { FILTER NOT EXISTS { $this ?value . } } + UNION { $this ?value . FILTER ( !( COALESCE( ?value = "approved" || ?value = "published", false ) ) ) } + }""" ] . + +A ``Document`` with a ``review_score`` of 4 or more violates the constraint +if it has no ``status``, or once for each ``status`` other than ``approved`` +or ``published``, which the result reports as ``sh:value``. SHACL processors +that support SHACL-SPARQL, such as ``pyshacl``, validate these constraints. + + Command Line ^^^^^^^^^^^^ diff --git a/docs/schemas/advanced.md b/docs/schemas/advanced.md index c56a808379..1be7ff82ef 100644 --- a/docs/schemas/advanced.md +++ b/docs/schemas/advanced.md @@ -129,7 +129,7 @@ classes: description: USA and territories must have a specific regex pattern for postal codes and phone numbers ``` -See above for implementation status +The [JSON Schema generator](../generators/json-schema) translates rules into `if` / `then` subschemas, and the [SHACL generator](../generators/shacl) into SHACL-SPARQL constraints. ## Defining slots From ee6954a4cee86ed117a244f5c0e14b11f11880c0 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Wed, 7 Oct 2026 10:20:36 +0200 Subject: [PATCH 19/30] test(gen-shacl): cover nested range_expression postconditions The composed rule translation handles a range_expression in a postcondition with the same code as in a precondition, but its tests exercised nested postconditions only in a closed world. Pin the cases specific to nested postconditions: - open_world: an omitted or empty wrapper satisfies the rule, and a present non-matching value violates it; - closed world: an empty wrapper satisfies the inner value condition, and every member of a multivalued wrapper must satisfy it; - an enum value with a meaning compares as that IRI, resolved on the range class; - a condition on the range class's identifier skips the rule. Signed-off-by: jdsika --- tests/linkml/test_generators/test_shaclgen.py | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index 468961d546..c5c568131e 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -4911,6 +4911,23 @@ def _inner(**conditions: dict) -> dict: id="post-nested-fails", ), pytest.param(_IF_MODE_M, {"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m"}, False, id="post-nested-absent"), + pytest.param( + _IF_MODE_M, {"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m", "part": {}}, True, id="post-nested-inner-absent" + ), + pytest.param( + _IF_MODE_M, + {"parts": _DEPTH_AT_MOST_ZERO}, + {"mode": "m", "parts": [{"depth": 0}, {"depth": -1}]}, + True, + id="post-nested-every-member", + ), + pytest.param( + _IF_MODE_M, + {"parts": _DEPTH_AT_MOST_ZERO}, + {"mode": "m", "parts": [{"depth": 0}, {"depth": 5}]}, + False, + id="post-nested-one-member-fails", + ), # equals_string_in in a precondition and an inner condition pytest.param({"mode": {"equals_string_in": ["m", "n"]}}, _REQUIRE_NOTE, {"mode": "n"}, False, id="pre-in"), pytest.param({"mode": {"equals_string_in": ["m", "n"]}}, _REQUIRE_NOTE, {"mode": "x"}, True, id="pre-in-other"), @@ -4957,6 +4974,9 @@ def test_compose_agrees_with_json_schema(pre, post, instance, valid, target_clas pytest.param({"note": {"value_presence": "ABSENT"}}, {"mode": "m"}, True, id="absent-met"), pytest.param({"note": {"equals_string": "n"}}, {"mode": "m"}, True, id="value-omitted"), pytest.param({"note": {"equals_string": "n"}}, {"mode": "m", "note": "x"}, False, id="value-other"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m"}, True, id="nested-omitted"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m", "part": {}}, True, id="nested-inner-absent"), + pytest.param({"part": _DEPTH_AT_MOST_ZERO}, {"mode": "m", "part": {"depth": 1}}, False, id="nested-other"), ], ) @pytest.mark.parametrize("target_class", ["Thing", "SubThing"]) @@ -5208,6 +5228,25 @@ def test_compose_boolean_precondition_compared_by_value(flag, violates): assert focus_nodes == ({EX_COMP.x} if violates else set()) +@pytest.mark.parametrize( + "kind,violates", + [ + pytest.param("ex:Special", False, id="meaning-iri"), + pytest.param('"Special"', True, id="permissible-value-text"), + pytest.param('"Plain"', True, id="other-value"), + ], +) +def test_compose_nested_postcondition_compares_enum_meaning(kind, violates): + """In a nested postcondition, an enum value with a ``meaning`` is compared as + that IRI, resolved on the range class, as ``rdflib_dumper`` writes it. The + generated JSON-LD context leaves the values of this enum, which has a value + without a meaning, as strings, so the expectations are explicit.""" + rule = _rule(_IF_MODE_M, {"part": _inner(kind={"equals_string": "Special"})}) + data = f'{_COMP_PREFIXES}ex:x a ex:Thing ; ex:mode "m" ; ex:part ex:p . ex:p ex:kind {kind} .' + _, focus_nodes = _validate_rules(_compose_schema(rule), data) + assert focus_nodes == ({EX_COMP.x} if violates else set()) + + # The numeric datatypes of SPARQL 1.1 §17.1, . _SPARQL_NUMERIC_DATATYPES = [ *("integer", "decimal", "float", "double"), @@ -5401,6 +5440,11 @@ def test_compose_bound_fails_on_a_value_that_is_not_a_number(level, violates): "'nope', which is not a slot", id="inner-unknown-slot", ), + pytest.param( + _rule(_IF_MODE_M, {"site": _inner(id={"equals_string": "ex:s1"})}), + "its condition is on the identifier slot 'id'", + id="inner-identifier", + ), ], ) def test_compose_untranslatable_rule_skipped(caplog, rule, reason): From edc7c1ad6657594888e67255eeaee5fdc0850570 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Thu, 7 May 2026 13:58:58 +0200 Subject: [PATCH 20/30] fix(shaclgen): emit sh:pattern for pattern constraints inside any_of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SHACL generator translated any_of branches by dispatching solely on `any.range` (class, type, enum, or simple datatype). If a branch specified `pattern:` — either alone or combined with a range — the constraint was silently dropped, producing an empty blank node `[ ]` (trivially satisfied) instead of the intended `[ sh:pattern "..." ]`. This is a problem for schemas that use pattern alternatives in `any_of`, such as the SPDX license field where valid values are either members of a fixed enum (SPDX identifiers), IRIs, or custom identifiers matching the LicenseRef- pattern defined in SPDX Specification v2.3 Annex D (ABNF: license-ref = ["DocumentRef-"(idstring)":"]"LicenseRef-"(idstring)). The fix adds a single check after the range dispatch: if any.pattern: g.add((range_list[-1], SH.pattern, Literal(any.pattern))) This correctly handles: - Pattern-only branches (no range): node gets only sh:pattern - Range + pattern branches: node gets both sh:datatype and sh:pattern - Range-only branches (no pattern): unchanged behaviour The test suite now includes a dedicated schema exercising all three cases, with assertions on both the generated RDF triples and pyshacl validation of conforming/non-conforming data. Signed-off-by: Carlo van Driesten --- .../linkml/src/linkml/generators/shaclgen.py | 5 + .../input/shaclgen/any_of_pattern.yaml | 59 ++++++ tests/linkml/test_generators/test_shaclgen.py | 172 ++++++++++++++++-- 3 files changed, 219 insertions(+), 17 deletions(-) create mode 100644 tests/linkml/test_generators/input/shaclgen/any_of_pattern.yaml diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index a587b0fd62..4e747e5b09 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -412,6 +412,11 @@ def st_node_pv(p, v): add_simple_data_type(st_node_pv, r) range_list.append(st_node) + # Propagate pattern constraint to the branch node. + # A branch may combine range + pattern (e.g. range: string + # with pattern: "^...") or specify pattern alone (no range). + if any.pattern: + g.add((range_list[-1], SH.pattern, Literal(any.pattern))) Collection(g, or_node, range_list) else: prop_pv_literal(SH.hasValue, s.equals_number) diff --git a/tests/linkml/test_generators/input/shaclgen/any_of_pattern.yaml b/tests/linkml/test_generators/input/shaclgen/any_of_pattern.yaml new file mode 100644 index 0000000000..5b247bb2a1 --- /dev/null +++ b/tests/linkml/test_generators/input/shaclgen/any_of_pattern.yaml @@ -0,0 +1,59 @@ +id: https://w3id.org/linkml/examples/any_of_pattern +name: test_any_of_pattern +description: >- + Test schema for pattern constraints inside any_of branches. + Exercises three cases: (1) pattern-only branch (no range), + (2) range + pattern on the same branch, (3) mixed branches + where some have pattern and some do not. +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://w3id.org/linkml/examples/any_of_pattern/ +imports: + - linkml:types +default_range: string +default_prefix: ex + +enums: + LicenseEnum: + permissible_values: + MIT: + Apache-2.0: + GPL-3.0-only: + +classes: + PatternOnlyBranch: + description: >- + A class where one any_of branch specifies only a pattern + (no range). The generated SHACL sh:or should contain a + node with sh:pattern but no sh:datatype or sh:class. + attributes: + license: + any_of: + - range: LicenseEnum + - range: uri + - pattern: "^LicenseRef-[a-zA-Z0-9\\-\\.]+$" + + RangeWithPattern: + description: >- + A class where an any_of branch combines range + pattern. + The generated SHACL sh:or node should have both sh:datatype + and sh:pattern. + attributes: + identifier: + any_of: + - range: string + pattern: "^[A-Z]{2}-[0-9]{4}$" + - range: integer + + MixedBranches: + description: >- + A class with three any_of branches: one with range only, + one with pattern only, one with range + pattern. Ensures + pattern is emitted only on branches that declare it. + attributes: + code: + any_of: + - range: integer + - pattern: "^CUSTOM-.*$" + - range: string + pattern: "^STD-[0-9]+$" diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index c5c568131e..dbed4c1827 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -2421,9 +2421,11 @@ def test_rule_sparql_syntax_valid(): prepareQuery(query_text) -# =========================================================================== +# ==================================================================== + # Exclusive-value pattern tests (SHACL §5 SPARQL constraints) -# =========================================================================== +# ==================================================================== + # # The "exclusive value" pattern translates a LinkML rule where: # - preconditions: slot X has equals_string (a specific enum value name) @@ -2440,7 +2442,8 @@ def test_rule_sparql_syntax_valid(): # - W3C SHACL §5 # - W3C SHACL §5.3.1 # - ISO 34503:2023, 9.3.6 (motivating use case: EdgeNone exclusivity) -# =========================================================================== +# ==================================================================== + _EXCLUSIVE_VALUE_SCHEMA_YAML = """ id: https://example.org/exclusive-value @@ -2840,9 +2843,11 @@ def test_shacl_modular_schema_with_reused_attribute_name(tmp_path) -> None: assert URIRef("https://example.org/domain/Pedido") in shapes -# =========================================================================== +# ==================================================================== + # Presence-implies-value pattern tests (enum guard) -# =========================================================================== +# ==================================================================== + # # The "presence implies value" pattern generalises the boolean guard to # enum-valued targets. It translates a LinkML rule where: @@ -2858,7 +2863,8 @@ def test_shacl_modular_schema_with_reused_attribute_name(tmp_path) -> None: # - W3C SHACL §5 # - W3C SHACL §5.3.1 # - W3C SHACL §5.3.2 -# =========================================================================== +# ==================================================================== + _PRESENCE_IMPLIES_VALUE_SCHEMA_YAML = """ id: https://example.org/presence-implies-value @@ -3113,9 +3119,11 @@ def test_presence_implies_value_result_names_path_and_value(instance, value): assert results == [(EX_PIV.x, EX_PIV.status, value)] -# =========================================================================== +# ==================================================================== + # equals_string / equals_string_in compare strings -# =========================================================================== +# ==================================================================== + # # The metamodel defines both operators for slots of range string: "the slot # must have range string and the value of the slot must equal the specified @@ -3132,7 +3140,8 @@ def test_presence_implies_value_result_names_path_and_value(instance, value): # - SPARQL 1.1 §17.4.1.3 # - SPARQL 1.1 §17.4.1.9 # - RDF 1.1 Concepts §3.3 -# =========================================================================== +# ==================================================================== + _RULE_TARGET_RANGE_SCHEMA_YAML = """ id: https://example.org/rule-target-range @@ -3764,13 +3773,15 @@ def test_rule_condition_metadata_does_not_block_translation(): assert len(_sparql_queries(_parse_shacl(schema), EX_RTR.Thing)) == 1 -# =========================================================================== +# ==================================================================== + # Rule inheritance # # A rule applies to "all members of this class" (metamodel `rules`), so a # class shape carries the rules of its ancestors and mixins, each translated # in the class's own context. -# =========================================================================== +# ==================================================================== + _RULE_INHERITANCE_SCHEMA_YAML = """ id: https://example.org/rule-inheritance @@ -3897,7 +3908,8 @@ def test_rule_shared_shape_reports_once(): assert results == [(EX_RI.x, EX_RI.target, EX_RI.Blue)] -# =========================================================================== +# ==================================================================== + # Slot resolution and value rendering # # A rule's SPARQL body must query the same IRI that ``sh:path`` emits for the @@ -3907,7 +3919,8 @@ def test_rule_shared_shape_reports_once(): # 3. a slot without a range takes the schema's `default_range`; # 4. built-in type names resolve without importing linkml:types; # 5. a condition on an unknown or identifier slot makes the rule untranslatable. -# =========================================================================== +# ==================================================================== + _ENUM_NARROWING_SCHEMA_YAML = """ @@ -4439,14 +4452,16 @@ def test_rule_equals_string_special_chars_escaped(): assert "\\\\" in query, f"backslash must be escaped, got:\n{query}" -# =========================================================================== +# ==================================================================== + # Operator exactness # # A rule is translated only when its conditions set exactly the operators a # pattern translates. Each rule below would match a pattern if the converter # dropped the extra operator, which would widen the precondition (false # positives) or weaken the postcondition (false negatives). -# =========================================================================== +# ==================================================================== + @pytest.mark.parametrize( @@ -4618,7 +4633,8 @@ def test_rule_equals_true_on_string_slot_pyshacl_end_to_end(status, violates): assert focus_nodes == ({URIRef("https://example.org/string-true/x")} if violates else set()) -# =========================================================================== +# ==================================================================== + # Compositional fallback # # A rule no named pattern matches is composed from its operators. Whether a @@ -4628,7 +4644,8 @@ def test_rule_equals_true_on_string_slot_pyshacl_end_to_end(status, violates): # slot must satisfy the condition: a slot constraint applies to all members of # a collection (05validation.md), as the JSON Schema generator's `items` reads # it; has_member requires some value to satisfy its condition. -# =========================================================================== +# ==================================================================== + EX_COMP = rdflib.Namespace("https://example.org/compose/") _COMP_PREFIXES = "@prefix ex: .\n@prefix xsd: .\n" @@ -5586,3 +5603,124 @@ def test_compose_range_expression_on_a_reference(inner, site, violates): data = f"{_COMP_PREFIXES}ex:x a ex:Thing ; ex:site ex:s1 . {site}" _, focus_nodes = _validate_rules(_compose_schema(rule), data) assert focus_nodes == ({EX_COMP.x} if violates else set()) + + +# --------------------------------------------------------------------------- +# pattern inside any_of branches +# --------------------------------------------------------------------------- + + +def test_any_of_with_pattern(input_path): + """Test that pattern constraints inside any_of branches emit sh:pattern. + + Exercises three cases: + 1. PatternOnlyBranch: any_of with a pattern-only branch (no range) + 2. RangeWithPattern: any_of with range + pattern on the same branch + 3. MixedBranches: combination of range-only, pattern-only, and range+pattern + """ + shacl = ShaclGenerator(input_path("shaclgen/any_of_pattern.yaml"), mergeimports=True).serialize() + g = rdflib.Graph() + g.parse(data=shacl) + + def get_or_branch_nodes(class_uri: str, slot_local: str) -> list[rdflib.BNode]: + """Return the list of BNodes inside sh:or for a given class property.""" + class_ref = URIRef(class_uri) + for prop_node in g.objects(class_ref, SH.property): + paths = list(g.objects(prop_node, SH.path)) + if any(slot_local in str(p) for p in paths): + for or_head in g.objects(prop_node, SH["or"]): + return list(Collection(g, or_head)) + return [] + + prefix = "https://w3id.org/linkml/examples/any_of_pattern/" + + # Case 1: PatternOnlyBranch — license slot has 3 branches: + # [enum sh:in], [sh:nodeKind sh:IRI], [sh:pattern "^LicenseRef-..."] + branches = get_or_branch_nodes(f"{prefix}PatternOnlyBranch", "license") + assert len(branches) == 3, f"Expected 3 branches, got {len(branches)}" + # Find the branch with sh:pattern + pattern_branches = [b for b in branches if list(g.objects(b, SH.pattern))] + assert len(pattern_branches) == 1, f"Expected 1 pattern branch, got {len(pattern_branches)}" + pattern_val = str(list(g.objects(pattern_branches[0], SH.pattern))[0]) + assert pattern_val == "^LicenseRef-[a-zA-Z0-9\\-\\.]+$" + # The pattern-only branch should NOT have sh:datatype or sh:class + assert list(g.objects(pattern_branches[0], SH.datatype)) == [] + assert list(g.objects(pattern_branches[0], SH["class"])) == [] + + # Case 2: RangeWithPattern — identifier slot has 2 branches: + # [sh:datatype xsd:string + sh:pattern "^[A-Z]{2}-[0-9]{4}$"], [sh:datatype xsd:integer] + branches = get_or_branch_nodes(f"{prefix}RangeWithPattern", "identifier") + assert len(branches) == 2, f"Expected 2 branches, got {len(branches)}" + # Find branch with both datatype and pattern + combo_branches = [b for b in branches if list(g.objects(b, SH.datatype)) and list(g.objects(b, SH.pattern))] + assert len(combo_branches) == 1, f"Expected 1 combo branch, got {len(combo_branches)}" + assert str(list(g.objects(combo_branches[0], SH.pattern))[0]) == "^[A-Z]{2}-[0-9]{4}$" + # The other branch (integer) should NOT have sh:pattern + int_branches = [b for b in branches if b not in combo_branches] + assert list(g.objects(int_branches[0], SH.pattern)) == [] + + # Case 3: MixedBranches — code slot has 3 branches: + # [sh:datatype xsd:integer], [sh:pattern "^CUSTOM-.*$"], [sh:datatype xsd:string + sh:pattern "^STD-[0-9]+$"] + branches = get_or_branch_nodes(f"{prefix}MixedBranches", "code") + assert len(branches) == 3, f"Expected 3 branches, got {len(branches)}" + # Exactly 2 branches should have sh:pattern + pattern_branches = [b for b in branches if list(g.objects(b, SH.pattern))] + assert len(pattern_branches) == 2, f"Expected 2 pattern branches, got {len(pattern_branches)}" + # Collect the patterns + patterns = sorted(str(list(g.objects(b, SH.pattern))[0]) for b in pattern_branches) + assert patterns == ["^CUSTOM-.*$", "^STD-[0-9]+$"] + # The integer-only branch should have no pattern + no_pattern = [b for b in branches if not list(g.objects(b, SH.pattern))] + assert len(no_pattern) == 1 + assert list(g.objects(no_pattern[0], SH.datatype)) == [URIRef("http://www.w3.org/2001/XMLSchema#integer")] + + +def test_any_of_with_pattern_pyshacl_end_to_end(input_path): + """End-to-end: pyshacl accepts values matching an ``any_of`` pattern branch and rejects others. + + This is the behavioural regression guard for the fix. Without ``sh:pattern`` on the + branch node, a pattern-only branch serialises as an empty shape ``[ ]``, which every + value node trivially satisfies — so ``sh:or`` would accept *anything* and the + non-conforming assertions below would fail. + """ + import pyshacl + + shacl_ttl = ShaclGenerator(input_path("shaclgen/any_of_pattern.yaml"), mergeimports=True).serialize() + + def conforms(data_ttl: str) -> tuple[bool, str]: + ok, _, text = pyshacl.validate( + data_graph=data_ttl, + shacl_graph=shacl_ttl, + data_graph_format="turtle", + shacl_graph_format="turtle", + ) + return ok, text + + prefixes = """ + @prefix ex: . + @prefix xsd: . + """ + + # Case 1: pattern-only branch. "MIT" satisfies the enum branch, an IRI satisfies the + # uri branch, and "LicenseRef-..." may only satisfy the pattern-only branch. + for value in ('"MIT"', "", '"LicenseRef-My-Custom.1"'): + ok, text = conforms(f"{prefixes}\nex:l1 a ex:PatternOnlyBranch ; ex:license {value} .") + assert ok, f"license {value} should conform:\n{text}" + # No branch matches: not an enum member, not an IRI, and does not match the pattern. + ok, _ = conforms(f'{prefixes}\nex:l2 a ex:PatternOnlyBranch ; ex:license "NotALicenseRef" .') + assert not ok, "A value matching no any_of branch must be rejected" + + # Case 2: range + pattern on the same branch — both must hold for that branch. + ok, text = conforms(f'{prefixes}\nex:i1 a ex:RangeWithPattern ; ex:identifier "AB-1234" .') + assert ok, f"identifier 'AB-1234' should conform:\n{text}" + ok, text = conforms(f'{prefixes}\nex:i2 a ex:RangeWithPattern ; ex:identifier "42"^^xsd:integer .') + assert ok, f"identifier 42 should conform via the integer branch:\n{text}" + ok, _ = conforms(f'{prefixes}\nex:i3 a ex:RangeWithPattern ; ex:identifier "ab-1234" .') + assert not ok, "A string violating the branch pattern must be rejected" + + # Case 3: mixed branches — each branch accepts only its own values. + for value in ('"7"^^xsd:integer', '"CUSTOM-anything"', '"STD-42"'): + ok, text = conforms(f"{prefixes}\nex:c1 a ex:MixedBranches ; ex:code {value} .") + assert ok, f"code {value} should conform:\n{text}" + ok, _ = conforms(f'{prefixes}\nex:c2 a ex:MixedBranches ; ex:code "STD-xyz" .') + assert not ok, "A value matching no any_of branch must be rejected" From 5eb4ac8b161fdce5d7b8a0b57b8eb1fafe23ba88 Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 24 Sep 2026 13:07:39 +0200 Subject: [PATCH 21/30] feat(generators): translate class-level boolean expressions alike to SHACL and JSON Schema gen-shacl dropped class-level any_of, all_of, exactly_one_of and none_of, so a class stating "a code or a name is required" generated shapes that accepted everything. gen-json-schema translated them, but only for the declaring class, and read an absent slot differently depending on where an expression was nested. Both generators now give class expressions one semantics, shared in linkml.generators.common.class_expression: - A class expression constrains every instance of its class, so a class carries the expressions of its ancestors and mixins, translated in its own context. - The specification does not say what a slot condition means for an absent slot. As an SQL CHECK constraint (ISO/IEC 9075) reads a condition on null, a condition that doesn't decide whether its slot may be absent is unknown for an absent slot. An instance is invalid only when an expression is definitely false, and exactly_one_of holds when exactly one member is definitely true. The operators compose, and gen-json-schema no longer rejects an instance meeting only the first member of an exactly_one_of while accepting one meeting none. gen-shacl emits sh:or, sh:and, sh:xone and sh:not (the operators' exact_mappings, SHACL 4.6) over anonymous member shapes, each in its "not false" form or, under sh:not, its "definitely true" form, where such a condition also has sh:minCount 1. Conditions translate required, value_presence, the cardinalities, minimum/maximum_value, pattern, equals_string(_in), equals_number and range; a parameter SHACL allows once per shape that one condition needs twice moves into an sh:and member. An operator whose members use anything else is skipped whole, with one warning naming the shapes it is missing from. gen-json-schema builds the same forms with anyOf, allOf, oneOf and not, resolves conditions on the induced slot, and also enforces cardinalities (minItems / maxItems) and range in class-level conditions. value_presence: ABSENT on several slots of one expression now requires each to be absent rather than not all of them present, in rules too. The slot loop's range dispatch and sh:path computation move into _add_range and _slot_iri; output for schemas without class expressions is unchanged. The compliance tests test_class_any_of and test_class_any_of_with_required run for SHACL. Signed-off-by: jdsika --- docs/generators/json-schema.rst | 7 + docs/generators/shacl.rst | 86 + docs/schemas/advanced.md | 30 + .../generators/common/class_expression.py | 96 ++ .../src/linkml/generators/jsonschemagen.py | 147 +- .../linkml/src/linkml/generators/shaclgen.py | 407 ++++- .../test_boolean_slot_compliance.py | 18 +- .../test_generators/test_jsonschemagen.py | 130 ++ tests/linkml/test_generators/test_shaclgen.py | 1461 +++++++++++++++++ .../test_validator/test_validation_context.py | 20 +- 10 files changed, 2330 insertions(+), 72 deletions(-) create mode 100644 packages/linkml/src/linkml/generators/common/class_expression.py diff --git a/docs/generators/json-schema.rst b/docs/generators/json-schema.rst index 2f8f1d8d91..bfc23e6558 100644 --- a/docs/generators/json-schema.rst +++ b/docs/generators/json-schema.rst @@ -130,6 +130,13 @@ LinkML supports analogous elements: Use of these elements will be translated into the appropriate JSON-Schema construct. +At the class level, a class carries the expressions of its ancestors and mixins. +A slot condition is unknown for an absent slot unless it decides whether the slot +may be absent, and an instance is invalid only when an expression is definitely +false, as described under "Class-level expressions and absent slots" in +:doc:`Advanced features `. +The SHACL generator reads them the same way. + Inlining ^^^^^^^^ diff --git a/docs/generators/shacl.rst b/docs/generators/shacl.rst index 4b4b35a7ad..05e77d5f18 100644 --- a/docs/generators/shacl.rst +++ b/docs/generators/shacl.rst @@ -181,6 +181,92 @@ or ``published``, which the result reports as ``sh:value``. SHACL processors that support SHACL-SPARQL, such as ``pyshacl``, validate these constraints. +Class Expressions +^^^^^^^^^^^^^^^^^ + +Class-level boolean expressions become the SHACL logical constraint components +their metamodel definitions map to (`SHACL §4.6 +`__): + +================== ===================================================== +LinkML SHACL, on the class's ``sh:NodeShape`` +================== ===================================================== +``any_of`` ``sh:or`` over the member shapes +``all_of`` ``sh:and`` over the member shapes +``exactly_one_of`` ``sh:xone`` over the member shapes +``none_of`` one ``sh:not`` per member +================== ===================================================== + +Each member becomes an anonymous node shape. ``is_a`` gives ``sh:class``, and +nested expressions recurse. Each entry of ``slot_conditions`` gives an +``sh:property`` whose path is that of the slot as induced for the class, so +``slot_usage`` applies: + +* ``required``, ``value_presence`` and the cardinalities give ``sh:minCount`` / + ``sh:maxCount``; +* ``minimum_value`` / ``maximum_value`` give ``sh:minInclusive`` / + ``sh:maxInclusive``, and ``equals_number`` gives both, so that ``5`` also + matches ``5.0``; +* ``pattern`` gives ``sh:pattern``; +* ``equals_string`` and ``equals_string_in`` give ``sh:in``; on an enum slot the + values are the permissible values as the enum renders them, the IRI of their + ``meaning`` where they have one; +* ``range`` gives the same class, type or enum constraint as a slot's range. + +A shape may have at most one value of ``sh:minInclusive``, ``sh:maxInclusive`` +or ``sh:in``, and of ``sh:pattern``, whose component also takes ``sh:flags`` +(`SHACL §2.1.1 `__). Where one condition +needs one of them twice, for example ``minimum_value`` next to +``equals_number``, the second value goes into an ``sh:and`` member of the +property shape, where it applies to the same values. + +A slot condition is unknown for an absent slot unless it decides whether the slot +may be absent, and an instance violates an expression only when the expression is +definitely false, as described under "Class-level expressions and absent slots" in +:doc:`Advanced features `. +The JSON Schema generator reads them the same way. In SHACL, each expression gets +its "not false" form, and, under ``sh:not``, its "definitely true" form, where +such a condition also requires its slot (``sh:minCount 1``). ``exactly_one_of`` +becomes ``sh:xone`` over the "definitely true" forms of its members. + +.. code-block:: yaml + + GeodeticReferenceSystem: + slots: [code, name] + any_of: + - slot_conditions: + code: + required: true + - slot_conditions: + name: + required: true + +.. code-block:: turtle + + ex:GeodeticReferenceSystem a sh:NodeShape ; + sh:or ( [ sh:property [ sh:path ex:code ; sh:minCount 1 ] ] + [ sh:property [ sh:path ex:name ; sh:minCount 1 ] ] ) ; + ... + +A class expression constrains every instance of its class, so the shape of a +class carries the expressions of its ancestors and mixins as well, as it carries +their slots. Each is translated in the context of that class, where +``slot_usage`` applies. A node typed only with a subclass, as ``rdflib_dumper`` +and the JSON-LD context produce it, is therefore checked without an +``rdfs:subClassOf`` triple in the data. The ``sh:class`` that ``is_a`` gives +recognises instances of subclasses only where the data graph states the +``rdfs:subClassOf`` (`SHACL §4.1.1 +`__), as it does for a +slot's range. + +An operator whose members use anything else is skipped as a whole and logged as +a warning, because leaving out one member would change what the operator +admits. That covers, for example, ``has_member`` or a slot-level ``any_of`` +inside a slot condition, a condition on a name that is not a slot, a condition +on the identifier slot (the node's IRI rather than a property), and +``equals_string`` on a slot whose range does not hold strings. + + Command Line ^^^^^^^^^^^^ diff --git a/docs/schemas/advanced.md b/docs/schemas/advanced.md index 1be7ff82ef..9911e158e5 100644 --- a/docs/schemas/advanced.md +++ b/docs/schemas/advanced.md @@ -64,6 +64,36 @@ The following LinkML constructs can be used to express boolean constraints: These can be applied at the class or slot level. The range of each of these is an array of *expressions*. +### Class-level expressions and absent slots + +At the class level, each member of `any_of`, `all_of`, `exactly_one_of` and `none_of` is a class expression whose `slot_conditions` constrain the instance's slots. The expressions of a class also constrain the instances of its subclasses, and of the classes that use it as a mixin. + +A slot condition needs a meaning when its slot is absent. The JSON Schema and SHACL generators read it as an SQL `CHECK` constraint reads a condition on a null value: an instance is invalid only when an expression is definitely false. + +- A condition that decides whether its slot may be absent is true or false as usual. Such a condition sets `value_presence: PRESENT` or `ABSENT`, `required: true`, a minimum or exact cardinality of at least 1, or a maximum or exact cardinality of 0. +- Any other condition is *unknown* when its slot is absent. +- `any_of`, `all_of` and `none_of` combine these as "or", "and" and "not". An unknown member doesn't make `any_of` true, nor `none_of` false. +- `exactly_one_of` holds when exactly one member is definitely true. + +```yaml +classes: + Sample: + none_of: + - slot_conditions: + status: + equals_string: retracted +``` + +A `Sample` without `status` is valid: the condition is unknown, so `none_of` isn't false. To require the slot, state it with `status: {required: true}`. + +| Expression | `{}` | `{label: A}` | `{label: B}` | +|---|---|---|---| +| `any_of: [label = A]` | valid | valid | invalid | +| `none_of: [label = A]` | valid | invalid | valid | +| `exactly_one_of: [label = A, note = B]` | invalid | valid | invalid | + +[Rules](#rules) are read differently: their preconditions require their slots, and so do their postconditions unless the rule is `open_world`. + ### Unions as ranges [any_of](https://w3id.org/linkml/any_of) can be used to express that a range must satisfy any of a set of ranges. diff --git a/packages/linkml/src/linkml/generators/common/class_expression.py b/packages/linkml/src/linkml/generators/common/class_expression.py new file mode 100644 index 0000000000..21ff99af4f --- /dev/null +++ b/packages/linkml/src/linkml/generators/common/class_expression.py @@ -0,0 +1,96 @@ +"""Shared semantics of slot conditions in class-level boolean expressions. + +The class-level operators ``any_of``, ``all_of``, ``exactly_one_of`` and +``none_of`` combine class expressions whose slot conditions constrain slots of +an instance. The specification does not say what a condition means for an +absent slot. Generators that translate class expressions read it the same +way, as an SQL CHECK constraint (ISO/IEC 9075) reads a condition on a null +value: a condition that doesn't state presence is *unknown* for an absent +slot, and an expression is violated only when it is definitely false. + +Each expression therefore has two forms: + +* its **"not false" form**, which the instance must satisfy, and in which a + condition holds for an absent slot unless it states presence; +* its **"definitely true" form**, used under a negation, in which a condition + that doesn't state presence also requires its slot. + +``none_of`` takes its members in the opposite form, ``exactly_one_of`` counts +the members that are definitely true, and ``any_of`` / ``all_of`` keep the +form. + +>>> from linkml_runtime.linkml_model.meta import SlotDefinition +>>> value_bounds(SlotDefinition("label", equals_string="A"), definite=False) +(0, None) +>>> value_bounds(SlotDefinition("label", equals_string="A"), definite=True) +(1, None) +>>> value_bounds(SlotDefinition("label", required=True, value_presence="ABSENT"), definite=True) +(0, 0) +>>> value_bounds(SlotDefinition("tags", minimum_cardinality=2, maximum_cardinality=3), definite=False) +(2, 3) +""" + +from linkml_runtime.linkml_model.meta import PresenceEnum, SlotDefinition + +_PRESENT = PresenceEnum(PresenceEnum.PRESENT) +_ABSENT = PresenceEnum(PresenceEnum.ABSENT) + + +def states_presence(condition: SlotDefinition) -> bool: + """Whether *condition* decides whether its slot may be absent. + + Such a condition is definitely true or false for an absent slot: + ``value_presence: PRESENT`` or ``ABSENT``; ``required: true``, unless + ``value_presence`` overrides it; a minimum or exact cardinality of at least + 1, which an absent slot fails; and a maximum or exact cardinality of 0, + which it satisfies. Other bounds, ``required: false`` and ``UNCOMMITTED`` + leave absence open, so adding one never changes the verdict on an absent + slot. + + >>> states_presence(SlotDefinition("label", equals_string="A")) + False + >>> states_presence(SlotDefinition("label", required=False, equals_string="A")) + False + >>> states_presence(SlotDefinition("tags", maximum_cardinality=5)) + False + >>> states_presence(SlotDefinition("tags", maximum_cardinality=0)) + True + >>> states_presence(SlotDefinition("tags", minimum_cardinality=1)) + True + """ + if condition.value_presence is not None: + if condition.value_presence in (_PRESENT, _ABSENT): + return True + elif condition.required: + return True + lower = (condition.minimum_cardinality, condition.exact_cardinality) + upper = (condition.maximum_cardinality, condition.exact_cardinality) + return any(bound is not None and bound >= 1 for bound in lower) or 0 in upper + + +def value_bounds(condition: SlotDefinition, definite: bool) -> tuple[int, int | None]: + """The least and the greatest number of values *condition* allows its slot. + + ``value_presence`` takes precedence over ``required``. In the "definitely + true" form (*definite*), a condition that doesn't state presence also + requires the slot. The greatest number is ``None`` when unbounded. + """ + lower, upper = [0], [] + if condition.value_presence is not None: + if condition.value_presence == _PRESENT: + lower.append(1) + elif condition.value_presence == _ABSENT: + upper.append(0) + elif condition.required: + lower.append(1) + if definite and not states_presence(condition): + lower.append(1) + for bound, target in ( + (condition.minimum_cardinality, lower), + (condition.exact_cardinality, lower), + (condition.maximum_cardinality, upper), + (condition.exact_cardinality, upper), + ): + if bound is not None: + target.append(int(bound)) + return max(lower), min(upper) if upper else None diff --git a/packages/linkml/src/linkml/generators/jsonschemagen.py b/packages/linkml/src/linkml/generators/jsonschemagen.py index 289c79515c..67770ffc65 100644 --- a/packages/linkml/src/linkml/generators/jsonschemagen.py +++ b/packages/linkml/src/linkml/generators/jsonschemagen.py @@ -14,6 +14,7 @@ from linkml.generators.common import build from linkml.generators.common.array import ArrayRangeGenerator, ArrayRepresentation from linkml.generators.common.build import RangeResult +from linkml.generators.common.class_expression import value_bounds from linkml.generators.common.lifecycle import LifecycleMixin from linkml.generators.common.subproperty import get_subproperty_values from linkml.generators.common.type_designators import ( @@ -275,24 +276,12 @@ def add_property( self["required"].append(canonical_name) # JSON Schema does not have a very natural way to express that a property cannot be present. - # The apparent best way to do it is to use: - # { - # properties: { - # foo: ... - # }, - # not: { - # required: ['foo'] - # } - # } - # The {required: [foo]} subschema evaluates to true if the foo property is present with any - # value. Wrapping that in a `not` keyword inverts that condition. + # The apparent best way to do it is `not: {required: [foo]}`: the {required: [foo]} + # subschema evaluates to true if the foo property is present with any value, and `not` + # inverts that. Each absent property gets its own `not`, in `allOf`, since + # `not: {required: [foo, bar]}` would only forbid foo and bar together. if value_disallowed: - if "not" not in self: - self["not"] = {} - if "required" not in self["not"]: - self["not"]["required"] = [] - - self["not"]["required"].append(canonical_name) + self.setdefault("allOf", []).append({"not": {"required": [canonical_name]}}) def add_keyword(self, keyword: str, value: Any): if value is None: @@ -623,29 +612,20 @@ def handle_class(self, cls: ClassDefinition) -> None: class_subschema["allOf"] = [] class_subschema["allOf"].extend(rule_subschemas) - if cls.any_of is not None and len(cls.any_of) > 0: - class_subschema["anyOf"] = [self.get_subschema_for_anonymous_class(c, False) for c in cls.any_of] - - if cls.all_of is not None and len(cls.all_of) > 0: - if "allOf" not in class_subschema: - class_subschema["allOf"] = [] - class_subschema["allOf"].extend([self.get_subschema_for_anonymous_class(c, False) for c in cls.all_of]) - - if cls.exactly_one_of is not None and len(cls.exactly_one_of) > 0: - class_subschema["oneOf"] = [self.get_subschema_for_anonymous_class(c, False) for c in cls.exactly_one_of] - - if cls.none_of is not None and len(cls.none_of) > 0: - # properties_required=True so absent slots make their branch fail; otherwise - # properties is vacuously true and `not(anyOf)` rejects instances missing the slot. - new_not = {"anyOf": [self.get_subschema_for_anonymous_class(c, True) for c in cls.none_of]} - if "not" in class_subschema: - existing_not = class_subschema.pop("not") - if "allOf" not in class_subschema: - class_subschema["allOf"] = [] - class_subschema["allOf"].append({"not": existing_not}) - class_subschema["allOf"].append({"not": new_not}) - else: - class_subschema["not"] = new_not + # A class expression constrains every instance of its class, so a class carries the + # expressions of its ancestors and mixins, translated in its own context. + for owner in self.schemaview.class_ancestors(cls.name): + for operator in self.CLASS_EXPRESSION_OPERATORS: + members = getattr(self.schemaview.get_class(owner), operator) or [] + if members: + expression = self.get_subschema_for_class_operator(cls, operator, members, definite=False) + for keyword, value in expression.items(): + if keyword == "allOf": + class_subschema.setdefault("allOf", []).extend(value) + elif keyword not in class_subschema: + class_subschema[keyword] = value + else: + class_subschema.setdefault("allOf", []).append({keyword: value}) class_subschema = self.after_generate_class( ClassResult.model_construct(schema_=class_subschema, source=cls), self.schemaview @@ -671,6 +651,93 @@ def handle_class(self, cls: ClassDefinition) -> None: if key not in self.top_level_schema: self.top_level_schema[key] = value + CLASS_EXPRESSION_OPERATORS = ("any_of", "all_of", "exactly_one_of", "none_of") + _CLASS_OPERATOR_KEYWORDS = {"any_of": "anyOf", "all_of": "allOf", "exactly_one_of": "oneOf"} + + def get_subschema_for_class_operator( + self, cls: ClassDefinition, operator: str, members: list[AnonymousClassExpression], definite: bool + ) -> JsonSchema: + """The subschema of the class-level boolean expression *operator* over *members*, for *cls*. + + A condition that doesn't state presence is unknown for an absent slot, + and an expression is violated only when it is definitely false (see + :mod:`linkml.generators.common.class_expression`). With *definite* the + subschema accepts the instances for which the expression is definitely + true, otherwise those for which it is not false. ``none_of`` takes its + members in the opposite form, ``exactly_one_of`` counts the members + that are definitely true, and ``any_of`` / ``all_of`` keep the form. + """ + if operator == "none_of": + return JsonSchema( + {"not": {"anyOf": [self.get_subschema_for_class_expression(cls, m, not definite) for m in members]}} + ) + form = True if operator == "exactly_one_of" else definite + return JsonSchema( + { + self._CLASS_OPERATOR_KEYWORDS[operator]: [ + self.get_subschema_for_class_expression(cls, m, form) for m in members + ] + } + ) + + def get_subschema_for_class_expression( + self, cls: ClassDefinition, expr: AnonymousClassExpression, definite: bool + ) -> JsonSchema: + """The subschema of one member *expr* of a class-level boolean expression of *cls*. + + Each slot condition constrains the slot as induced for *cls*, so + ``slot_usage`` applies. Its values must satisfy its value operators + and its ``range``; the number of values must lie within + :func:`~linkml.generators.common.class_expression.value_bounds`, where + an empty list counts as no value. *definite* selects the form, as in + :meth:`get_subschema_for_class_operator`. + """ + subschema = JsonSchema() + conjuncts: list[JsonSchema] = [] + for slot_name, condition in expr.slot_conditions.items(): + slot = self._class_expression_slot(cls, slot_name) or condition + if self.use_curies: + prop_name = self._curie(slot) + else: + prop_name = self.aliased_slot_name(slot) + values = self.get_subschema_for_slot(condition, omit_type=True, include_null=False) + if condition.range is not None: + typed = self.get_subschema_for_slot( + SlotDefinition( + slot.name, range=condition.range, inlined=slot.inlined, inlined_as_list=slot.inlined_as_list + ), + include_null=False, + ) + values = JsonSchema({"allOf": [values, typed]}) if values else typed + lower, upper = value_bounds(condition, definite) + if slot.multivalued: + prop = JsonSchema.array_of(values, include_null=False, required=False) + prop.add_keyword("minItems", lower or None) + prop.add_keyword("maxItems", upper) + subschema.add_property(prop_name, prop, value_required=lower > 0) + else: + subschema.add_property(prop_name, values, value_required=lower > 0, value_disallowed=upper == 0) + if lower > 1: + # a single-valued slot holds at most one value + conjuncts.append(JsonSchema({"not": {}})) + for operator in self.CLASS_EXPRESSION_OPERATORS: + members = getattr(expr, operator) or [] + if members: + conjuncts.append(self.get_subschema_for_class_operator(cls, operator, members, definite)) + if expr.is_a is not None: + # `is_a: ` in a class expression requires instances of the expression to be instances of . + conjuncts.append(self.get_subschema_for_slot(AnonymousSlotExpression(range=expr.is_a))) + if conjuncts: + subschema.setdefault("allOf", []).extend(conjuncts) + return subschema + + def _class_expression_slot(self, cls: ClassDefinition, slot_name: str) -> SlotDefinition | None: + """The slot a condition of a class expression of *cls* names, as induced for *cls*, if any.""" + sv = self.schemaview + if slot_name in sv.class_slots(cls.name): + return sv.induced_slot(slot_name, cls.name) + return sv.get_slot(slot_name) + def get_subschema_for_anonymous_class( self, cls: AnonymousClassExpression, properties_required: bool = False ) -> None | JsonSchema: diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 4e747e5b09..59b5204520 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -13,6 +13,7 @@ from rdflib.namespace import RDF, RDFS, SH, XSD from linkml._version import __version__ +from linkml.generators.common.class_expression import value_bounds from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range from linkml.generators.shacl.shacl_data_type import ShaclDataType from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor @@ -23,6 +24,7 @@ AnonymousSlotExpression, ClassDefinition, ClassRule, + ClassExpression, Element, ElementName, PresenceEnum, @@ -261,6 +263,8 @@ def as_graph(self) -> Graph: for pfx in self.schema.prefixes.values(): g.bind(str(pfx.prefix_prefix), pfx.prefix_reference) + self._class_expressions_added: set[tuple[URIRef, str, str]] = set() + self._class_expression_problems: dict[tuple[str, str, str], list[str]] = {} for c in sv.all_classes(imports=not self.exclude_imports).values(): def shape_pv(p, v): @@ -301,12 +305,7 @@ def shape_pv(p, v): self._add_annotations(shape_pv, c) order = 0 for s in sv.class_induced_slots(c.name): - # fixed in linkml-runtime 1.1.3 - if s.name in sv.element_by_schema_map(): - slot_uri = URIRef(sv.get_uri(s, expand=True)) - else: - pfx = sv.schema.default_prefix - slot_uri = URIRef(sv.expand_curie(f"{pfx}:{underscore(s.name)}")) + slot_uri = URIRef(self._slot_iri(s)) pnode = BNode() shape_pv(SH.property, pnode) @@ -429,21 +428,7 @@ def st_node_pv(p, v): f" require range 'string' and not '{r}'" ) - if r in all_classes: - cls_def = sv.get_class(r) - is_any = cls_def and getattr(cls_def, "class_uri", None) == "linkml:Any" - self._add_class(prop_pv, r) - if not is_any: - if sv.get_identifier_slot(r) is not None: - prop_pv(SH.nodeKind, SH.IRI) - else: - prop_pv(SH.nodeKind, SH.BlankNodeOrIRI) - elif r in sv.all_types(): - self._add_type(prop_pv, r) - elif r in sv.all_enums(): - self._add_enum(g, prop_pv, r) - else: - add_simple_data_type(prop_pv, r) + self._add_range(g, prop_pv, r) if s.pattern: prop_pv(SH.pattern, Literal(s.pattern)) if s.equals_string: @@ -463,14 +448,363 @@ def st_node_pv(p, v): if default_value: prop_pv(SH.defaultValue, default_value) + self._add_class_expressions(g, class_uri_with_suffix, c) + if self.emit_rules: self._add_rules(g, class_uri_with_suffix, c) self._report_rule_problems() + self._report_class_expression_problems() return g LINKML_ANY_URI = "https://w3id.org/linkml/Any" + # ------------------------------------------------------------------- + # Class expressions → sh:or / sh:and / sh:xone / sh:not + # ------------------------------------------------------------------- + + # The SHACL logical constraint component, taking a list, of each list operator. + _LIST_OPERATORS = {"any_of": SH["or"], "all_of": SH["and"], "exactly_one_of": SH.xone} + _CLASS_EXPRESSION_OPERATORS = ("any_of", "all_of", "exactly_one_of", "none_of") + + # The fields of an anonymous class expression that carry meaning, derived from + # the metamodel, so that a new semantic field is reported as untranslatable + # instead of being ignored. Every other field is metadata. + _CLASS_EXPRESSION_FIELDS = frozenset(f.name for f in fields(ClassExpression)) | {"is_a"} + + # Slot-condition fields translated by _slot_condition_shape: those deciding + # whether the slot is present, and those constraining its values. + _SLOT_CONDITION_PRESENCE_FIELDS = frozenset( + {"required", "value_presence", "minimum_cardinality", "maximum_cardinality", "exact_cardinality"} + ) + _SLOT_CONDITION_VALUE_FIELDS = frozenset( + {"minimum_value", "maximum_value", "pattern", "equals_string", "equals_string_in", "equals_number", "range"} + ) + _SLOT_CONDITION_FIELDS = _SLOT_CONDITION_PRESENCE_FIELDS | _SLOT_CONDITION_VALUE_FIELDS + + # Parameters a shape may have at most one value of: sh:minInclusive and + # sh:maxInclusive (SHACL §4.3), sh:in (§4.8.3), sh:datatype and sh:nodeKind + # (§4.1), and sh:pattern, as a parameter of a component with more than one + # parameter (§2.1.1). A condition that needs one of them twice gets the + # second value in an sh:and member. + _SINGLE_VALUE_PARAMETERS = frozenset( + {SH.minInclusive, SH.maxInclusive, SH["in"], SH.pattern, SH.datatype, SH.nodeKind} + ) + + # Fields on a slot condition / class expression that carry no constraint + # semantics: they never change which instances satisfy the condition, so + # they are ignored by the operator accounting below. Anything set on a + # condition that is neither here nor explicitly translated by a converter + # makes the rule untranslatable — the converters must SKIP such a rule + # rather than emit a query that silently drops a conjunct (which would + # widen the trigger or narrow the check: a mis-translation, not a skip). + # Derived from the metamodel: the metadata every ``element`` carries, minus + # anything that is a ``slot_expression`` operator. + _NON_OPERATOR_FIELDS = frozenset(f.name for f in fields(Element)) - frozenset( + f.name for f in fields(SlotExpression) + ) + + @classmethod + def _set_operator_fields( + cls, condition: SlotDefinition | AnonymousSlotExpression | AnonymousClassExpression + ) -> set[str]: + """Return the names of the constraint-bearing fields actually set on a + rule condition or class expression. + + A field counts as *set* when it is not ``None`` and not an empty + collection (SchemaView materialises unset multivalued fields as empty + lists / dicts). Scalars are never judged by truthiness, so legitimate + falsy constraints such as ``minimum_value: 0`` or + ``equals_string: ""`` still count as set. Metadata fields + (:data:`_NON_OPERATOR_FIELDS`) are excluded. + + The converters compare this set against the exact operator set they + translate and skip the rule on any mismatch, so an unrecognised (or + future-metamodel) operator can never be silently dropped. + """ + return { + name + for name, value in vars(condition).items() + if not name.startswith("_") + and name not in cls._NON_OPERATOR_FIELDS + and value is not None + and not (isinstance(value, list | dict) and not value) + } + + def _add_class_expressions(self, g: Graph, shape_uri: URIRef, cls: ClassDefinition) -> None: + """Emit the class-level boolean expressions of *cls* as SHACL logical constraints. + + Each operator is mapped to the SHACL logical constraint component with the + same semantics (`SHACL §4.6 `_): + + * ``any_of`` → ``sh:or``, ``all_of`` → ``sh:and``, ``exactly_one_of`` → + ``sh:xone``, each over a list of the member shapes; + * ``none_of`` → one ``sh:not`` per member. A shape's values of ``sh:not`` + are separate constraints that all apply (SHACL §2.1.1), so the node must + conform to none of the members. + + Every member becomes an anonymous node shape: ``is_a`` gives ``sh:class``, + each slot condition gives an ``sh:property`` on the path of the slot as + induced for *cls* (so ``slot_usage`` applies), and nested expressions + recurse. + + The specification doesn't say what a slot condition means for an absent + slot. As with an SQL CHECK constraint (ISO/IEC 9075), which is + satisfied unless its condition is false, a class expression is violated + only when it is definitely false, and a condition that doesn't state + presence (:func:`~linkml.generators.common.class_expression.states_presence`) is unknown + for an absent slot. + Each operator is therefore translated in its "not false" form, and, + under a negation, in its "definitely true" form, where such a condition + requires its slot. ``exactly_one_of`` holds when exactly one member is + definitely true. + + A class expression constrains every instance of its class, so the shape + of *cls* carries the expressions of its ancestors and mixins as well, as + it carries their slots. Each is translated in the context of *cls*, + where ``slot_usage`` may refine a slot it names. Classes that share a + ``class_uri`` share one shape, which carries each expression once. + + An operator whose members use anything that cannot be translated is + skipped as a whole, with a warning: dropping one member would change what + the operator admits. + """ + sv = self.schemaview + for owner in sv.class_ancestors(cls.name): + for operator in self._CLASS_EXPRESSION_OPERATORS: + members = getattr(sv.get_class(owner), operator, None) or [] + if not members or (shape_uri, owner, operator) in self._class_expressions_added: + continue + self._class_expressions_added.add((shape_uri, owner, operator)) + reason = next(filter(None, (self._untranslatable(cls, m) for m in members)), None) + if reason is not None: + classes = self._class_expression_problems.setdefault((owner, operator, reason), []) + if cls.name not in classes: + classes.append(cls.name) + continue + self._add_logical_constraint(g, shape_uri, cls, operator, members, definite=False) + + def _report_class_expression_problems(self) -> None: + """Warn once about each class expression that is not translated, naming the + class shapes it is missing from unless that is only the declaring class.""" + for (owner, operator, reason), classes in self._class_expression_problems.items(): + shapes = "" if classes == [owner] else f" (in the shapes of {', '.join(map(repr, classes))})" + logger.warning( + "Class %r: %s is not translated to SHACL, because it uses %s%s.", owner, operator, reason, shapes + ) + + def _add_logical_constraint( + self, + g: Graph, + subject: URIRef | BNode, + cls: ClassDefinition, + operator: str, + members: list[AnonymousClassExpression], + definite: bool, + ) -> None: + """Add the logical constraint for *operator* over *members* to *subject*. + + With *definite* the constraint holds when the expression is definitely + true, otherwise when it is not false. ``none_of`` gives one ``sh:not`` + per member, in the opposite form, since an expression is not false + exactly when its negation is not definitely true. ``exactly_one_of`` + counts the members that are definitely true, in either form. The + other list operators keep the form. + """ + if operator == "none_of": + for member in members: + g.add((subject, SH["not"], self._class_expression_shape(g, cls, member, not definite))) + return + predicate = self._LIST_OPERATORS[operator] + member_form = True if operator == "exactly_one_of" else definite + shapes = [self._class_expression_shape(g, cls, m, member_form) for m in members] + list_node = BNode() + Collection(g, list_node, shapes) + g.add((subject, predicate, list_node)) + + def _class_expression_shape( + self, g: Graph, cls: ClassDefinition, expr: AnonymousClassExpression, definite: bool + ) -> BNode: + """Build the anonymous node shape for one class expression *expr*, in its + "definitely true" form with *definite*, otherwise its "not false" form.""" + node = BNode() + + def node_pv(p, v): + if v is not None: + g.add((node, p, v)) + + if expr.title is not None: + node_pv(RDFS.label, Literal(expr.title, lang=self._resolve_language(expr))) + if expr.description is not None: + node_pv(RDFS.comment, Literal(expr.description, lang=self._resolve_language(expr))) + if expr.is_a is not None: + self._add_class(node_pv, expr.is_a) + for slot_name, condition in expr.slot_conditions.items(): + node_pv(SH.property, self._slot_condition_shape(g, cls, slot_name, condition, definite)) + for operator in self._CLASS_EXPRESSION_OPERATORS: + members = getattr(expr, operator) or [] + if members: + self._add_logical_constraint(g, node, cls, operator, members, definite) + return node + + def _slot_condition_shape( + self, g: Graph, cls: ClassDefinition, slot_name: str, condition: SlotDefinition, definite: bool + ) -> BNode: + """Build the property shape for the condition on *slot_name*, in its + "definitely true" form with *definite*, otherwise its "not false" form.""" + slot = self._condition_slot(cls, slot_name) + pnode = BNode() + repeated = [] + + def prop_pv(p, v): + if v is None: + return + if p in self._SINGLE_VALUE_PARAMETERS and (pnode, p, None) in g: + repeated.append((p, v)) + else: + g.add((pnode, p, v)) + + prop_pv(SH.path, URIRef(self._slot_iri(slot))) + if condition.title is not None: + prop_pv(SH.name, Literal(condition.title, lang=self._resolve_language(condition))) + if condition.description is not None: + prop_pv(SH.description, Literal(condition.description, lang=self._resolve_language(condition))) + + lower, upper = value_bounds(condition, definite) + if lower: + prop_pv(SH.minCount, Literal(lower)) + if upper is not None: + prop_pv(SH.maxCount, Literal(upper)) + + if condition.minimum_value is not None: + prop_pv(SH.minInclusive, Literal(condition.minimum_value)) + if condition.maximum_value is not None: + prop_pv(SH.maxInclusive, Literal(condition.maximum_value)) + if condition.pattern is not None: + prop_pv(SH.pattern, Literal(condition.pattern)) + value_range = condition.range or slot.range + for values in ( + [condition.equals_string] if condition.equals_string is not None else [], + condition.equals_string_in, + ): + if values: + in_node = BNode() + Collection(g, in_node, self._string_value_terms(value_range, values)) + prop_pv(SH["in"], in_node) + if condition.equals_number is not None: + # A value comparison, unlike the slot loop's sh:hasValue: 5 matches 5.0, + # and like every other value constraint in a condition it holds when the + # slot is absent. + prop_pv(SH.minInclusive, Literal(condition.equals_number)) + prop_pv(SH.maxInclusive, Literal(condition.equals_number)) + if condition.range is not None: + self._add_range(g, prop_pv, condition.range) + if repeated: + # Each repeated parameter in a member shape of its own: all of them hold + # for every value, as they would on the property shape itself. + members = [] + for p, v in repeated: + member = BNode() + g.add((member, p, v)) + members.append(member) + and_node = BNode() + Collection(g, and_node, members) + g.add((pnode, SH["and"], and_node)) + return pnode + + def _string_value_terms(self, r: ElementName | None, values: list[str]) -> list[URIRef | Literal]: + """The RDF terms of the ``equals_string`` / ``equals_string_in`` *values* of a slot with range *r*. + + Permissible values of an enum are rendered as :meth:`_add_enum` renders them, as + the IRI of their ``meaning`` where they have one; anything else is a plain literal. + """ + sv = self.schemaview + if r in sv.all_enums(): + pvs = sv.get_enum(r).permissible_values + return [ + URIRef(sv.expand_curie(pvs[v].meaning)) if v in pvs and pvs[v].meaning else Literal(v) for v in values + ] + return [Literal(v) for v in values] + + def _condition_slot(self, cls: ClassDefinition, slot_name: str) -> SlotDefinition | None: + """The slot a condition of *cls* names, as induced for *cls*, or ``None`` if there is none.""" + try: + return self.schemaview.induced_slot(slot_name, cls.name) + except ValueError: + return None + + def _type_uri(self, r: ElementName | None) -> str | None: + """The expanded datatype IRI of type range *r*, or ``None`` when *r* is not a type. + + Resolved through the induced type, so a type derived with ``typeof`` + inherits the ``uri`` of its ancestor. A built-in type name in a schema + that does not import ``linkml:types`` resolves as the main slot loop + resolves it (:class:`ShaclDataType`). + """ + sv = self.schemaview + if r in sv.all_types(): + return sv.get_uri(sv.induced_type(r), expand=True) + builtin = next((t for t in ShaclDataType if t.linkml_type == r), None) + return str(builtin.uri_ref) if builtin is not None else None + + def _is_string_range(self, r: ElementName | None) -> bool: + """Whether a slot with range *r* holds strings, which ``equals_string`` compares against. + + True for an enum, whose permissible values are rendered as their + ``meaning`` IRI or as a plain literal (as :meth:`_add_enum` renders + them); for a type whose datatype is ``xsd:string``, whose values are + plain literals; and for no range at all, whose values are untyped and + compared as strings, as the JSON Schema generator compares them. A + type with any other datatype, including one derived from ``string`` + (``xsd:anyURI``, ``xsd:token``, ...), holds typed literals or IRIs + that a string literal does not match. + """ + if r is None or r in self.schemaview.all_enums(): + return True + return self._type_uri(r) == str(XSD.string) + + def _untranslatable(self, cls: ClassDefinition, expr: AnonymousClassExpression) -> str | None: + """Return what in class expression *expr* of *cls* cannot be translated, or ``None``.""" + sv = self.schemaview + unknown = self._set_operator_fields(expr) - self._CLASS_EXPRESSION_FIELDS + if unknown: + return f"'{sorted(unknown)[0]}'" + if expr.is_a is not None and expr.is_a not in sv.all_classes(): + return f"is_a '{expr.is_a}', which is not a class" + for slot_name, condition in expr.slot_conditions.items(): + unknown = self._set_operator_fields(condition) - self._SLOT_CONDITION_FIELDS + if unknown: + return f"'{sorted(unknown)[0]}' in the condition on slot '{slot_name}'" + slot = self._condition_slot(cls, slot_name) + if slot is None: + return f"a condition on '{slot_name}', which is not a slot" + if slot.identifier: + # An identifier is the node's IRI, not a property arc. + return f"a condition on the identifier slot '{slot_name}'" + if condition.range is not None and not self._is_known_range(condition.range): + return f"the unknown range '{condition.range}' in the condition on slot '{slot_name}'" + value_range = condition.range or slot.range + if (condition.equals_string is not None or condition.equals_string_in) and not self._is_string_range( + value_range + ): + return f"equals_string on slot '{slot_name}', whose range '{value_range}' does not hold strings" + for operator in self._CLASS_EXPRESSION_OPERATORS: + for member in getattr(expr, operator) or []: + reason = self._untranslatable(cls, member) + if reason is not None: + return reason + return None + + def _is_known_range(self, r: ElementName) -> bool: + """Whether *r* names a class, type or enum of the schema, or a built-in type.""" + sv = self.schemaview + return ( + r in sv.all_classes() + or r in sv.all_types() + or r in sv.all_enums() + or any(datatype.linkml_type == r for datatype in ShaclDataType) + ) + # ------------------------------------------------------------------- # Rules → sh:sparql # ------------------------------------------------------------------- @@ -1374,6 +1708,37 @@ def _add_class(self, func: Callable, r: ElementName) -> None: range_ref += self.suffix func(SH["node"], URIRef(range_ref)) + def _slot_iri(self, slot: SlotDefinition) -> str: + """The full IRI of *slot*, exactly as ``sh:path`` in the main slot loop renders it. + + An induced slot carries its ``slot_usage`` overrides, so an overridden + ``slot_uri`` yields the same IRI as ``sh:path``; otherwise the query + would use a property the data never uses and never fire. + """ + sv = self.schemaview + if slot.name in sv.element_by_schema_map(): + return sv.get_uri(slot, expand=True) + return sv.expand_curie(f"{sv.schema.default_prefix}:{underscore(slot.name)}") + + def _add_range(self, g: Graph, func: Callable, r: ElementName) -> None: + """Add the value-type constraint for range *r*: a class, type, enum or built-in datatype.""" + sv = self.schemaview + if r in sv.all_classes(): + cls_def = sv.get_class(r) + is_any = cls_def and getattr(cls_def, "class_uri", None) == "linkml:Any" + self._add_class(func, r) + if not is_any: + if sv.get_identifier_slot(r) is not None: + func(SH.nodeKind, SH.IRI) + else: + func(SH.nodeKind, SH.BlankNodeOrIRI) + elif r in sv.all_types(): + self._add_type(func, r) + elif r in sv.all_enums(): + self._add_enum(g, func, r) + else: + add_simple_data_type(func, r) + def _add_enum(self, g: Graph, func: Callable, r: ElementName) -> None: sv = self.schemaview enum = sv.get_enum(r) diff --git a/tests/linkml/test_compliance/test_boolean_slot_compliance.py b/tests/linkml/test_compliance/test_boolean_slot_compliance.py index fd900bf938..e73ff46851 100644 --- a/tests/linkml/test_compliance/test_boolean_slot_compliance.py +++ b/tests/linkml/test_compliance/test_boolean_slot_compliance.py @@ -505,7 +505,7 @@ def test_class_any_of(framework, data_name, s1value, s2value, is_valid): core_elements=["any_of", "ClassDefinition"], ) expected_behavior = ValidationBehavior.IMPLEMENTS - if framework not in [OWL]: + if framework not in [OWL, SHACL]: # TODO: rdflib transformer has issues around ranges expected_behavior = ValidationBehavior.INCOMPLETE # TODO: rdflib transformer has issues around ranges @@ -632,8 +632,22 @@ def test_class_any_of_with_required(framework, nest, op, name, family_name, give core_elements=[op, "ClassDefinition"], ) expected_behavior = ValidationBehavior.IMPLEMENTS - if framework not in [JSON_SCHEMA]: + if framework not in [JSON_SCHEMA, SHACL]: expected_behavior = ValidationBehavior.INCOMPLETE + elif framework == SHACL and 5 in (name, family_name, given_name): + # SHACL validation makes its instances through python dataclasses, which coerce + # the integer to a string, so the range violation never reaches the shapes. A + # row can then only be detected through the operator itself. + present = [value is not None for value in (name, family_name, given_name)] + members = [present[0], present[1] and present[2]] + operator_holds = { + "any_of": any(members), + "all_of": all(members), + "exactly_one_of": sum(members) == 1, + "none_of": not any(members), + }[op] + if operator_holds: + expected_behavior = ValidationBehavior.INCOMPLETE data = {SLOT_S1: name, SLOT_S2: family_name, SLOT_S3: given_name} if nest: diff --git a/tests/linkml/test_generators/test_jsonschemagen.py b/tests/linkml/test_generators/test_jsonschemagen.py index 12bd533962..b3fd57baa1 100644 --- a/tests/linkml/test_generators/test_jsonschemagen.py +++ b/tests/linkml/test_generators/test_jsonschemagen.py @@ -1837,3 +1837,133 @@ def test_identifier_property_keeps_slot_name(tmp_path, use_curies): instance = {"id": "ex:thing1", name_key: "x"} jsonschema.validate(instance, {**generated, "$ref": f"#/$defs/{'ex:MyClass' if use_curies else 'MyClass'}"}) + + +_CLASS_EXPRESSION_INHERITANCE_SCHEMA = """ +id: https://example.org/class-expressions +name: class_expressions +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/class-expressions/ +imports: + - linkml:types +default_prefix: ex +default_range: string +slots: + a: {} + b: {} +classes: + Marker: + mixin: true + none_of: + - slot_conditions: + a: + equals_string: forbidden + Parent: + slots: [a, b] + any_of: + - slot_conditions: + a: + required: true + - slot_conditions: + b: + required: true + Child: + is_a: Parent + mixins: [Marker] + GrandChild: + is_a: Child +""" + + +@pytest.mark.parametrize("target_class", ["Parent", "Child", "GrandChild"]) +@pytest.mark.parametrize( + "instance,valid_for_parent,valid_for_marker_users", + [ + pytest.param({"a": "1"}, True, True, id="a"), + pytest.param({"b": "2"}, True, True, id="b"), + pytest.param({}, False, False, id="neither"), + pytest.param({"a": "forbidden"}, True, False, id="forbidden-by-mixin"), + ], +) +def test_class_expressions_apply_to_subclasses(target_class, instance, valid_for_parent, valid_for_marker_users): + """A class expression constrains every instance of its class, so the + definitions of its subclasses, and of the classes using a mixin, carry it.""" + json_schema = json.loads( + JsonSchemaGenerator(_CLASS_EXPRESSION_INHERITANCE_SCHEMA, top_class=target_class).serialize() + ) + expected = valid_for_parent if target_class == "Parent" else valid_for_marker_users + assert jsonschema.Draft7Validator(json_schema).is_valid(instance) == expected + + +_TWO_ABSENT_RULE_SCHEMA = """ +id: https://example.org/absent +name: absent +prefixes: + linkml: https://w3id.org/linkml/ +imports: + - linkml:types +default_range: string +classes: + Thing: + attributes: + trigger: {} + a: {} + b: {} + rules: + - preconditions: + slot_conditions: + trigger: + value_presence: PRESENT + postconditions: + slot_conditions: + a: + value_presence: ABSENT + b: + value_presence: ABSENT +""" + + +@pytest.mark.parametrize( + "instance,valid", + [ + pytest.param({"trigger": "t"}, True, id="neither"), + pytest.param({"trigger": "t", "a": "x"}, False, id="one"), + pytest.param({"trigger": "t", "a": "x", "b": "y"}, False, id="both"), + pytest.param({"a": "x", "b": "y"}, True, id="not-triggered"), + ], +) +def test_several_absent_slots_each_must_be_absent(instance, valid): + """value_presence: ABSENT on several slots of one expression requires each + to be absent, not merely that they aren't all present.""" + json_schema = json.loads(JsonSchemaGenerator(_TWO_ABSENT_RULE_SCHEMA, top_class="Thing").serialize()) + assert jsonschema.Draft7Validator(json_schema).is_valid(instance) == valid + + +@pytest.mark.parametrize("tags,valid", [(["A"], True), (["A", "B"], False), ([], True)]) +def test_class_expression_condition_uses_the_induced_slot(tags, valid): + """A condition constrains the slot as induced for the class, so a + slot_usage that makes it multivalued applies the condition to every value.""" + schema = """ +id: https://example.org/induced +name: induced +prefixes: + linkml: https://w3id.org/linkml/ +imports: + - linkml:types +default_range: string +slots: + tags: {} +classes: + Thing: + slots: [tags] + slot_usage: + tags: + multivalued: true + all_of: + - slot_conditions: + tags: + equals_string: A +""" + json_schema = json.loads(JsonSchemaGenerator(schema, top_class="Thing").serialize()) + assert jsonschema.Draft7Validator(json_schema).is_valid({"tags": tags}) == valid diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index dbed4c1827..fb236cab44 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -6,6 +6,7 @@ import pytest import rdflib +import yaml from rdflib import RDF, RDFS, SH, XSD, Literal, URIRef from rdflib.collection import Collection @@ -5724,3 +5725,1463 @@ def conforms(data_ttl: str) -> tuple[bool, str]: assert ok, f"code {value} should conform:\n{text}" ok, _ = conforms(f'{prefixes}\nex:c2 a ex:MixedBranches ; ex:code "STD-xyz" .') assert not ok, "A value matching no any_of branch must be rejected" + + +# --------------------------------------------------------------------------- +# Class expressions → sh:or / sh:and / sh:xone / sh:not +# --------------------------------------------------------------------------- + +EX_CE = rdflib.Namespace("https://example.org/class-expressions/") + +_CLASS_EXPRESSION_HEADER = """ +id: https://example.org/class-expressions +name: class_expressions +prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/class-expressions/ +imports: + - linkml:types +default_prefix: ex +default_range: string +""" + +_REFERENCE_SYSTEM_SCHEMA = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + codeEPSG: + range: integer + coordinateSystemName: {{}} +classes: + ReferenceSystem: + class_uri: ex:ReferenceSystem + slots: [codeEPSG, coordinateSystemName] + {operator}: + - slot_conditions: + codeEPSG: + required: true + - slot_conditions: + coordinateSystemName: + required: true +""" +) + +_LOGICAL_PREDICATES = (SH["or"], SH["and"], SH.xone, SH["not"]) + + +def _list_members(g, shape, predicate): + """Members of the single SHACL list that *shape* has for *predicate*.""" + lists = list(g.objects(shape, predicate)) + assert len(lists) == 1, f"expected one {predicate} list on {shape}, got {len(lists)}" + return list(Collection(g, lists[0])) + + +def _condition(g, member, path): + """The property shape for *path* inside the member shape *member*.""" + shapes = [p for p in g.objects(member, SH.property) if (p, SH.path, path) in g] + assert len(shapes) == 1, f"expected one condition on {path}, got {len(shapes)}" + return shapes[0] + + +def _conforms(shacl_ttl: str, data_ttl: str) -> bool: + """Validate *data_ttl*; meta_shacl makes pyshacl fail on an ill-formed shapes graph.""" + import pyshacl + + conforms, _, _ = pyshacl.validate( + data_graph=data_ttl, + shacl_graph=shacl_ttl, + data_graph_format="turtle", + shacl_graph_format="turtle", + advanced=True, + meta_shacl=True, + ) + return conforms + + +@pytest.mark.parametrize( + "operator,predicate", + [("any_of", SH["or"]), ("all_of", SH["and"]), ("exactly_one_of", SH.xone)], +) +def test_class_expression_list_operator_generates_logical_constraint(operator, predicate): + """any_of, all_of and exactly_one_of become sh:or, sh:and and sh:xone over the member shapes.""" + g = _parse_shacl(_REFERENCE_SYSTEM_SCHEMA.format(operator=operator)) + + members = _list_members(g, EX_CE.ReferenceSystem, predicate) + assert len(members) == 2 + for member, path in zip(members, (EX_CE.codeEPSG, EX_CE.coordinateSystemName)): + condition = _condition(g, member, path) + assert (condition, SH.minCount, Literal(1)) in g + assert (member, SH.targetClass, None) not in g + others = set(_LOGICAL_PREDICATES) - {predicate} + assert not any((EX_CE.ReferenceSystem, p, None) in g for p in others) + + +def test_class_expression_none_of_generates_one_sh_not_per_member(): + """none_of becomes one sh:not per member; the negated constraints all apply (SHACL §2.1.1).""" + g = _parse_shacl(_REFERENCE_SYSTEM_SCHEMA.format(operator="none_of")) + + negated = list(g.objects(EX_CE.ReferenceSystem, SH["not"])) + assert len(negated) == 2 + paths = {path for member in negated for p in g.objects(member, SH.property) for path in g.objects(p, SH.path)} + assert paths == {EX_CE.codeEPSG, EX_CE.coordinateSystemName} + + +@pytest.mark.parametrize( + "operator,properties,expected", + [ + ("any_of", 'ex:codeEPSG 4326 ; ex:coordinateSystemName "WGS 84"', True), + ("any_of", "ex:codeEPSG 4326", True), + ("any_of", "", False), + ("exactly_one_of", "ex:codeEPSG 4326", True), + ("exactly_one_of", 'ex:codeEPSG 4326 ; ex:coordinateSystemName "WGS 84"', False), + ("exactly_one_of", "", False), + ("all_of", 'ex:codeEPSG 4326 ; ex:coordinateSystemName "WGS 84"', True), + ("all_of", "ex:codeEPSG 4326", False), + ("none_of", "", True), + ("none_of", "ex:codeEPSG 4326", False), + ], +) +def test_class_expression_pyshacl_end_to_end(operator, properties, expected): + """End-to-end: each operator admits exactly the instances the metamodel says it holds for.""" + shacl_ttl = ShaclGenerator(_REFERENCE_SYSTEM_SCHEMA.format(operator=operator), mergeimports=False).serialize() + data = f""" + @prefix ex: . + ex:rs a ex:ReferenceSystem {";" if properties else ""} {properties} . + """ + assert _conforms(shacl_ttl, data) is expected + + +_FORMAT_PROFILES_SCHEMA = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + fileFormat: {} + formatType: {} + version: {} + hasChannel: + range: Channel + multivalued: true + inlined: true +classes: + Channel: + class_uri: ex:Channel + Format: + class_uri: ex:Format + slots: [fileFormat, formatType, version, hasChannel] + any_of: + - description: A single-channel trace. + slot_conditions: + fileFormat: + equals_string_in: [OSI, TXTH] + formatType: + required: true + version: + required: true + - description: A multi-channel container. + slot_conditions: + fileFormat: + required: true + equals_string: MCAP + hasChannel: + required: true +""" +) + + +@pytest.mark.parametrize( + "properties,expected", + [ + ('ex:fileFormat "OSI" ; ex:formatType "SensorView" ; ex:version "3.7.0"', True), + ('ex:fileFormat "MCAP" ; ex:hasChannel ex:ch', True), + ('ex:fileFormat "MCAP" ; ex:formatType "SensorView" ; ex:version "3.7.0"', False), + ('ex:fileFormat "OSI" ; ex:formatType "SensorView" ; ex:hasChannel ex:ch', False), + ], +) +def test_class_expression_any_of_profiles_pyshacl_end_to_end(properties, expected): + """End-to-end: a class-level any_of selects between two complete profiles of a class.""" + shacl_ttl = ShaclGenerator(_FORMAT_PROFILES_SCHEMA, mergeimports=False).serialize() + data = f""" + @prefix ex: . + ex:ch a ex:Channel . + ex:f a ex:Format ; {properties} . + """ + assert _conforms(shacl_ttl, data) is expected + + +def test_class_expression_member_metadata(): + """A member's title and description annotate its shape, as they do for a class's NodeShape.""" + g = _parse_shacl(_FORMAT_PROFILES_SCHEMA) + + comments = {str(c) for m in _list_members(g, EX_CE.Format, SH["or"]) for c in g.objects(m, RDFS.comment)} + assert comments == {"A single-channel trace.", "A multi-channel container."} + + +_CONDITIONS_SCHEMA = ( + _CLASS_EXPRESSION_HEADER + + """ +enums: + ColourEnum: + permissible_values: + red: {} + blue: {} +slots: + label: {} + size: + range: integer + count: + multivalued: true + exact: + multivalued: true + colour: {} + target: {} + note: {} +classes: + Target: + class_uri: ex:Target + Thing: + class_uri: ex:Thing + slots: [label, size, count, exact, colour, target, note] + all_of: + - title: every condition + slot_conditions: + label: + description: Starts upper case. + required: true + pattern: "^[A-Z]" + equals_string_in: [Alpha, Beta] + size: + minimum_value: 1 + maximum_value: 10 + equals_number: 5 + count: + required: true + minimum_cardinality: 2 + maximum_cardinality: 4 + exact: + exact_cardinality: 3 + colour: + range: ColourEnum + target: + value_presence: PRESENT + range: Target + note: + value_presence: ABSENT +""" +) + + +def test_class_expression_slot_condition_fields(): + """Each supported slot-condition field maps to the SHACL constraint the slot loop uses for it.""" + g = _parse_shacl(_CONDITIONS_SCHEMA) + (member,) = _list_members(g, EX_CE.Thing, SH["and"]) + assert (member, RDFS.label, Literal("every condition")) in g + + def values(path, predicate): + return set(g.objects(_condition(g, member, path), predicate)) + + def in_list(path): + (node,) = values(path, SH["in"]) + return list(Collection(g, node)) + + assert values(EX_CE.label, SH.minCount) == {Literal(1)} + assert values(EX_CE.label, SH.pattern) == {Literal("^[A-Z]")} + assert values(EX_CE.label, SH.description) == {Literal("Starts upper case.")} + assert in_list(EX_CE.label) == [Literal("Alpha"), Literal("Beta")] + + # equals_number is a value comparison; SHACL allows one sh:minInclusive and one + # sh:maxInclusive per shape, so next to the bounds it moves into an sh:and member + assert values(EX_CE.size, SH.minInclusive) == {Literal(1)} + assert values(EX_CE.size, SH.maxInclusive) == {Literal(10)} + (and_node,) = values(EX_CE.size, SH["and"]) + repeated = {(p, o) for m in Collection(g, and_node) for p, o in g.predicate_objects(m)} + assert repeated == {(SH.minInclusive, Literal(5)), (SH.maxInclusive, Literal(5))} + assert values(EX_CE.size, SH["in"]) == set() + assert values(EX_CE.size, SH.minCount) == set() + assert values(EX_CE.size, SH.hasValue) == set() + + # required and a cardinality give one sh:minCount, the stricter of the two + assert values(EX_CE["count"], SH.minCount) == {Literal(2)} + assert values(EX_CE["count"], SH.maxCount) == {Literal(4)} + assert values(EX_CE.exact, SH.minCount) == {Literal(3)} + assert values(EX_CE.exact, SH.maxCount) == {Literal(3)} + + assert in_list(EX_CE.colour) == [Literal("red"), Literal("blue")] + + assert values(EX_CE.target, SH.minCount) == {Literal(1)} + assert values(EX_CE.target, SH["class"]) == {EX_CE.Target} + assert values(EX_CE.target, SH.nodeKind) == {SH.BlankNodeOrIRI} + + assert values(EX_CE.note, SH.maxCount) == {Literal(0)} + assert values(EX_CE.note, SH.minCount) == set() + + +@pytest.mark.parametrize( + "condition,properties,expected", + [ + # outside none_of a value constraint says nothing about presence + ("any_of", "", True), + ("any_of", 'ex:label "b"', False), + # inside none_of it requires the slot, so an absent slot is not rejected + ("none_of", "", True), + ("none_of", 'ex:label "A"', False), + ("none_of", 'ex:label "B"', True), + ], +) +def test_class_expression_presence_semantics(condition, properties, expected): + """A condition holds vacuously for an absent slot, except under none_of, as in the JSON Schema generator.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + f""" +slots: + label: {{}} +classes: + Thing: + class_uri: ex:Thing + slots: [label] + {condition}: + - slot_conditions: + label: + {"pattern: '^[A-Z]'" if condition == "any_of" else "equals_string: A"} +""" + ) + shacl_ttl = ShaclGenerator(schema, mergeimports=False).serialize() + data = f""" + @prefix ex: . + ex:t a ex:Thing {";" if properties else ""} {properties} . + """ + assert _conforms(shacl_ttl, data) is expected + + +def test_class_expression_nested_and_is_a(): + """Nested expressions recurse into the member shape; is_a gives sh:class.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + a: {} + b: {} + c: {} +classes: + Marker: + class_uri: ex:Marker + Thing: + class_uri: ex:Thing + slots: [a, b, c] + any_of: + - all_of: + - slot_conditions: + a: + required: true + - slot_conditions: + b: + required: true + - is_a: Marker + slot_conditions: + c: + required: true +""" + ) + # open shapes: the instance is also a Marker, whose own shape declares no slots + shacl_ttl = ShaclGenerator(schema, mergeimports=False, closed=False).serialize() + g = rdflib.Graph().parse(data=shacl_ttl) + first, second = _list_members(g, EX_CE.Thing, SH["or"]) + assert len(_list_members(g, first, SH["and"])) == 2 + assert (second, SH["class"], EX_CE.Marker) in g + + prefix = "@prefix ex: ." + assert _conforms(shacl_ttl, f'{prefix} ex:t a ex:Thing ; ex:a "1" ; ex:b "2" .') + assert not _conforms(shacl_ttl, f'{prefix} ex:t a ex:Thing ; ex:a "1" .') + assert _conforms(shacl_ttl, f'{prefix} ex:t a ex:Thing, ex:Marker ; ex:c "3" .') + assert not _conforms(shacl_ttl, f'{prefix} ex:t a ex:Thing ; ex:c "3" .') + + +_INHERITED_CLASS_EXPRESSION_SCHEMA = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + a: {} + b: {} +classes: + Marker: + class_uri: ex:Marker + mixin: true + none_of: + - slot_conditions: + a: + equals_string: forbidden + Parent: + class_uri: ex:Parent + slots: [a, b] + any_of: + - slot_conditions: + a: + required: true + - slot_conditions: + b: + required: true + Child: + class_uri: ex:Child + is_a: Parent + mixins: [Marker] + GrandChild: + class_uri: ex:GrandChild + is_a: Child +""" +) +_CE_PREFIXES = ( + "@prefix ex: .\n@prefix rdfs: .\n" +) + + +@pytest.mark.parametrize("subclass_axioms", [False, True], ids=["typed-only", "with-rdfs-subClassOf"]) +@pytest.mark.parametrize("class_name", ["Parent", "Child", "GrandChild"]) +@pytest.mark.parametrize( + "values,conforms", + [ + pytest.param(' ; ex:a "1"', True, id="a"), + pytest.param(' ; ex:b "2"', True, id="b"), + pytest.param("", False, id="neither"), + ], +) +def test_class_expression_applies_to_subclass_instances(subclass_axioms, class_name, values, conforms): + """A class expression constrains every instance of its class, so the shapes + of its subclasses carry it too. A node typed only with a subclass, as + rdflib_dumper and the JSON-LD context produce it, is checked without an + rdfs:subClassOf triple in the data; with one, the verdict is the same.""" + shacl_ttl = ShaclGenerator(_INHERITED_CLASS_EXPRESSION_SCHEMA, mergeimports=False, closed=False).serialize() + axioms = ( + "ex:Child rdfs:subClassOf ex:Parent . ex:GrandChild rdfs:subClassOf ex:Child .\n" if subclass_axioms else "" + ) + assert _conforms(shacl_ttl, f"{_CE_PREFIXES}{axioms}ex:x a ex:{class_name}{values} .") == conforms + + +@pytest.mark.parametrize("class_name,conforms", [("Parent", True), ("Child", False), ("GrandChild", False)]) +def test_class_expression_of_a_mixin_applies_to_the_classes_using_it(class_name, conforms): + """A mixin's class expression constrains the classes that use the mixin and + their subclasses, and no other class.""" + shacl_ttl = ShaclGenerator(_INHERITED_CLASS_EXPRESSION_SCHEMA, mergeimports=False, closed=False).serialize() + assert _conforms(shacl_ttl, f'{_CE_PREFIXES}ex:x a ex:{class_name} ; ex:a "forbidden" .') == conforms + + +def test_inherited_class_expression_follows_the_subclass_slot_usage(): + """An inherited class expression is translated in the subclass's context, so + its conditions use the subclass's slot_usage, as the subclass's own + property shapes do.""" + schema = yaml.safe_load(_INHERITED_CLASS_EXPRESSION_SCHEMA) + schema["classes"]["Child"]["slot_usage"] = {"a": {"slot_uri": "ex:childA"}} + g = rdflib.Graph().parse(data=ShaclGenerator(json.dumps(schema), mergeimports=False).serialize()) + for shape, path in ((EX_CE.Parent, EX_CE.a), (EX_CE.Child, EX_CE.childA), (EX_CE.GrandChild, EX_CE.childA)): + (or_list,) = g.objects(shape, SH["or"]) + paths = { + p + for member in Collection(g, or_list) + for prop in g.objects(member, SH.property) + for p in g.objects(prop, SH.path) + } + assert paths == {path, EX_CE.b}, (shape, paths) + + +def test_classes_sharing_a_class_uri_carry_an_inherited_expression_once(): + """Classes with the same class_uri share one shape, which carries an + expression they inherit once.""" + schema = yaml.safe_load(_INHERITED_CLASS_EXPRESSION_SCHEMA) + schema["classes"]["Child"]["class_uri"] = "ex:Shared" + schema["classes"]["GrandChild"]["class_uri"] = "ex:Shared" + g = rdflib.Graph().parse(data=ShaclGenerator(json.dumps(schema), mergeimports=False).serialize()) + assert len(list(g.objects(EX_CE.Shared, SH["or"]))) == 1 + assert len(list(g.objects(EX_CE.Shared, SH["not"]))) == 1 + + +@pytest.mark.parametrize( + "child_usage,expected_shapes,warning", + [ + pytest.param( + None, + {EX_CE.Parent: False, EX_CE.Child: False, EX_CE.GrandChild: False}, + "Class 'Parent': any_of is not translated to SHACL, because it uses 'has_member' in the condition on " + "slot 'a' (in the shapes of 'Parent', 'Child', 'GrandChild').", + id="untranslatable-everywhere", + ), + pytest.param( + {"a": {"range": "integer"}}, + {EX_CE.Parent: True, EX_CE.Child: False, EX_CE.GrandChild: False}, + "Class 'Parent': any_of is not translated to SHACL, because it uses equals_string on slot 'a', whose " + "range 'integer' does not hold strings (in the shapes of 'Child', 'GrandChild').", + id="untranslatable-in-subclasses", + ), + ], +) +def test_untranslatable_inherited_class_expression_warned_once(caplog, child_usage, expected_shapes, warning): + """An inherited expression that cannot be translated is skipped in the + shapes where it cannot, and reported once, naming them.""" + schema = yaml.safe_load(_INHERITED_CLASS_EXPRESSION_SCHEMA) + schema["classes"]["Parent"]["any_of"] = ( + [{"slot_conditions": {"a": {"has_member": {"equals_string": "x"}}}}] + if child_usage is None + else [{"slot_conditions": {"a": {"equals_string": "x"}}}] + ) + if child_usage is not None: + schema["classes"]["Child"]["slot_usage"] = child_usage + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + g = rdflib.Graph().parse(data=ShaclGenerator(json.dumps(schema), mergeimports=False).serialize()) + for shape, translated in expected_shapes.items(): + assert ((shape, SH["or"], None) in g) == translated, shape + messages = [rec.message for rec in caplog.records if "any_of is not translated" in rec.message] + assert messages == [warning] + + +def test_class_expression_untranslatable_operator_skipped_with_warning(caplog): + """An operator with an untranslatable member is skipped whole, with a warning; the others are kept.""" + import logging + + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + tags: + multivalued: true + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [tags, a] + any_of: + - slot_conditions: + tags: + has_member: + equals_string: x + - slot_conditions: + a: + required: true + none_of: + - slot_conditions: + a: + equals_string: forbidden +""" + ) + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + g = _parse_shacl(schema) + + assert (EX_CE.Thing, SH["or"], None) not in g + assert any( + "any_of" in rec.message and "has_member" in rec.message and "tags" in rec.message for rec in caplog.records + ) + # the translatable none_of is kept, and enforced + shacl_ttl = g.serialize(format="turtle") + assert not _conforms(shacl_ttl, f'{_CE_PREFIXES}ex:t a ex:Thing ; ex:a "forbidden" .') + assert _conforms(shacl_ttl, f'{_CE_PREFIXES}ex:t a ex:Thing ; ex:a "ok" .') + + +def test_class_expression_condition_on_identifier_skipped_with_warning(caplog): + """An identifier is the node's IRI, not a property arc, so a condition on it is not translated.""" + import logging + + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + id: + identifier: true + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [id, a] + exactly_one_of: + - slot_conditions: + id: + pattern: "^ex:" + - slot_conditions: + a: + required: true +""" + ) + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + g = _parse_shacl(schema) + + assert (EX_CE.Thing, SH.xone, None) not in g + assert any("exactly_one_of" in rec.message and "identifier" in rec.message for rec in caplog.records) + + +def test_class_expression_absent_leaves_node_shapes_unchanged(): + """Without class-level expressions no node shape gets a logical constraint; slot-level any_of is unaffected.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + value: + any_of: + - range: integer + - range: string +classes: + Thing: + class_uri: ex:Thing + slots: [value] +""" + ) + g = _parse_shacl(schema) + + for shape in g.subjects(SH.targetClass, None): + assert not any((shape, p, None) in g for p in _LOGICAL_PREDICATES) + (value_shape,) = [p for p in g.objects(EX_CE.Thing, SH.property) if (p, SH.path, EX_CE.value) in g] + assert (value_shape, SH["or"], None) in g + + +def test_class_expression_condition_path_is_the_slot_induced_for_the_class(): + """A condition's sh:path is the one the class's own property shape uses, slot_usage and attributes included.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + code: + slot_uri: ex:baseCode + exact mappings: + slot_uri: ex:exactMatch +classes: + Thing: + class_uri: ex:Thing + slots: [code, exact mappings] + slot_usage: + code: + slot_uri: ex:thingCode + attributes: + loc: + slot_uri: ex:thingLoc + any_of: + - slot_conditions: + code: + required: true + - slot_conditions: + exact_mappings: + required: true + - slot_conditions: + loc: + required: true + Other: + class_uri: ex:Other + attributes: + loc: + slot_uri: ex:otherLoc +""" + ) + g = _parse_shacl(schema) + + class_paths = {path for p in g.objects(EX_CE.Thing, SH.property) for path in g.objects(p, SH.path)} + condition_paths = [ + path + for member in _list_members(g, EX_CE.Thing, SH["or"]) + for p in g.objects(member, SH.property) + for path in g.objects(p, SH.path) + ] + assert condition_paths and set(condition_paths) <= class_paths + assert set(condition_paths) == {EX_CE.thingCode, EX_CE.exactMatch, EX_CE.thingLoc} + + +_PRESENCE_SCHEMA = ( + _CLASS_EXPRESSION_HEADER + + """ +enums: + Color: + permissible_values: + red: {} + green: {} +slots: + label: {} + note: {} + tags: + multivalued: true +classes: + Thing: + class_uri: ex:Thing + tree_root: true + slots: [label, note, tags] +""" +) + + +def _class_expression_verdicts(expression: dict, obj: dict) -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL shapes generated for ``Thing`` with + the class-level *expression* accept *obj*. For SHACL, *obj* is loaded + through the generated JSON-LD context.""" + import jsonschema + import pyshacl + + from linkml.generators.jsonldcontextgen import ContextGenerator + from linkml.generators.jsonschemagen import JsonSchemaGenerator + + schema = yaml.safe_load(_PRESENCE_SCHEMA) + schema["classes"]["Thing"].update(expression) + schema = yaml.safe_dump(schema) + json_schema = json.loads(JsonSchemaGenerator(schema, top_class="Thing").serialize()) + context = json.loads(ContextGenerator(schema).serialize())["@context"] + data = rdflib.Graph().parse(data=json.dumps({"@context": context, "@type": "Thing", **obj}), format="json-ld") + shapes = rdflib.Graph().parse(data=ShaclGenerator(schema, mergeimports=False, closed=False).serialize()) + conforms, _, _ = pyshacl.validate(data, shacl_graph=shapes, meta_shacl=True) + return jsonschema.Draft7Validator(json_schema).is_valid(obj), conforms + + +def _is_a(value: str) -> dict: + """A class expression requiring ``label`` to equal *value*.""" + return {"slot_conditions": {"label": {"equals_string": value}}} + + +def _tags(**condition) -> dict: + """A class expression with *condition* on the multivalued ``tags``.""" + return {"slot_conditions": {"tags": condition}} + + +_LABEL_A_OR_NOTE_B = [_is_a("A"), {"slot_conditions": {"note": {"equals_string": "B"}}}] + + +@pytest.mark.parametrize( + "expression,obj,valid", + [ + # a condition is unknown for an absent slot, and only a definitely false expression is a violation + pytest.param({"any_of": [_is_a("A")]}, {}, True, id="any_of-absent"), + pytest.param({"any_of": [_is_a("A")]}, {"label": "Z"}, False, id="any_of-other"), + pytest.param({"any_of": _LABEL_A_OR_NOTE_B}, {"label": "Z"}, True, id="any_of-one-false-one-unknown"), + pytest.param({"none_of": [_is_a("A")]}, {}, True, id="none_of-absent"), + pytest.param({"none_of": [_is_a("A")]}, {"label": "A"}, False, id="none_of-match"), + pytest.param({"none_of": [_is_a("A")]}, {"label": "B"}, True, id="none_of-other"), + pytest.param({"none_of": [{"any_of": [_is_a("A"), _is_a("B")]}]}, {}, True, id="nested-in-none_of-absent"), + pytest.param( + {"none_of": [{"any_of": [_is_a("A"), _is_a("B")]}]}, {"label": "B"}, False, id="nested-in-none_of" + ), + pytest.param({"none_of": [{"any_of": [_is_a("A"), _is_a("B")]}]}, {"label": "C"}, True, id="nested-other"), + # the reading composes: wrapping in a one-member all_of, or negating twice, changes nothing + pytest.param({"all_of": [{"none_of": [_is_a("A")]}]}, {}, True, id="none_of-in-all_of-absent"), + pytest.param({"all_of": [{"none_of": [_is_a("A")]}]}, {"label": "A"}, False, id="none_of-in-all_of-match"), + pytest.param({"none_of": [{"none_of": [_is_a("A")]}]}, {}, True, id="double-negation-absent"), + pytest.param({"none_of": [{"none_of": [_is_a("A")]}]}, {"label": "A"}, True, id="double-negation-match"), + pytest.param({"none_of": [{"none_of": [_is_a("A")]}]}, {"label": "B"}, False, id="double-negation-other"), + # stated presence is definite; required: false only restates the default + pytest.param( + {"none_of": [{"slot_conditions": {"label": {"required": False, "equals_string": "A"}}}]}, + {}, + True, + id="restated-default", + ), + pytest.param( + {"none_of": [{"slot_conditions": {"label": {"value_presence": "ABSENT", "equals_string": "A"}}}]}, + {}, + False, + id="stated-absent", + ), + pytest.param( + {"none_of": [{"slot_conditions": {"label": {"value_presence": "ABSENT", "equals_string": "A"}}}]}, + {"label": "A"}, + True, + id="stated-absent-present", + ), + pytest.param( + {"all_of": [{"slot_conditions": {"note": {"required": True, "value_presence": "ABSENT"}}}]}, + {}, + True, + id="value_presence-over-required", + ), + pytest.param( + {"all_of": [{"slot_conditions": {"note": {"required": True, "value_presence": "ABSENT"}}}]}, + {"note": "x"}, + False, + id="value_presence-over-required-present", + ), + # an empty condition is unknown for an absent slot, and definitely true for a present one + pytest.param({"none_of": [{"slot_conditions": {"label": {}}}]}, {}, True, id="empty-condition-absent"), + pytest.param({"none_of": [{"slot_conditions": {"label": {}}}]}, {"label": "x"}, False, id="empty-condition"), + # exactly_one_of holds when exactly one member is definitely true + pytest.param({"exactly_one_of": _LABEL_A_OR_NOTE_B}, {}, False, id="exactly_one_of-none-known"), + pytest.param({"exactly_one_of": _LABEL_A_OR_NOTE_B}, {"label": "A"}, True, id="exactly_one_of-first"), + pytest.param({"exactly_one_of": _LABEL_A_OR_NOTE_B}, {"label": "Z"}, False, id="exactly_one_of-none-true"), + pytest.param( + {"exactly_one_of": _LABEL_A_OR_NOTE_B}, {"label": "A", "note": "X"}, True, id="exactly_one_of-one-of-two" + ), + pytest.param( + {"exactly_one_of": _LABEL_A_OR_NOTE_B}, {"label": "A", "note": "B"}, False, id="exactly_one_of-both" + ), + # cardinalities count the values of a list, of which an absent slot has none + pytest.param({"all_of": [_tags(minimum_cardinality=2)]}, {"tags": ["a"]}, False, id="minimum-cardinality"), + pytest.param({"all_of": [_tags(minimum_cardinality=2)]}, {"tags": ["a", "b"]}, True, id="minimum-met"), + pytest.param({"all_of": [_tags(minimum_cardinality=2)]}, {}, False, id="minimum-absent"), + pytest.param({"all_of": [_tags(maximum_cardinality=1)]}, {"tags": ["a", "b"]}, False, id="maximum-cardinality"), + pytest.param({"none_of": [_tags(maximum_cardinality=1)]}, {}, True, id="none_of-maximum-absent"), + pytest.param({"none_of": [_tags(maximum_cardinality=1)]}, {"tags": ["a"]}, False, id="none_of-maximum"), + pytest.param( + {"none_of": [_tags(maximum_cardinality=1)]}, {"tags": ["a", "b"]}, True, id="none_of-maximum-over" + ), + pytest.param({"any_of": [_tags(maximum_cardinality=0), _is_a("A")]}, {}, True, id="maximum-zero-absent"), + # a single-valued slot holds at most one value + pytest.param( + {"all_of": [{"slot_conditions": {"label": {"minimum_cardinality": 2}}}]}, + {"label": "x"}, + False, + id="single-valued-minimum-two", + ), + # a range in a condition narrows the slot's values + pytest.param( + {"all_of": [{"slot_conditions": {"label": {"range": "Color"}}}]}, {"label": "red"}, True, id="range" + ), + pytest.param( + {"all_of": [{"slot_conditions": {"label": {"range": "Color"}}}]}, {"label": "blue"}, False, id="range-other" + ), + pytest.param({"all_of": [{"slot_conditions": {"label": {"range": "Color"}}}]}, {}, True, id="range-absent"), + pytest.param( + {"none_of": [{"slot_conditions": {"label": {"range": "Color"}}}]}, + {"label": "red"}, + False, + id="none_of-range", + ), + ], +) +def test_class_expression_absent_slot_semantics(expression, obj, valid): + """The specification doesn't say what a slot condition means for an absent + slot. As with an SQL CHECK constraint, an expression is violated only when + it is definitely false: a condition that doesn't state presence is unknown + for an absent slot. exactly_one_of holds when exactly one member is + definitely true. The JSON Schema and SHACL generators decide alike.""" + assert _class_expression_verdicts(expression, obj) == (valid, valid) + + +@pytest.mark.parametrize( + "condition,tags,conforms", + [ + # a bound an absent slot satisfies and a present one may too leaves absence open + pytest.param({"maximum_cardinality": 1}, [], True, id="at-most-one-absent"), + pytest.param({"maximum_cardinality": 1}, ["a"], False, id="at-most-one"), + pytest.param({"maximum_cardinality": 1}, ["a", "b"], True, id="more-than-one"), + # a maximum of 0 decides that the slot is absent + pytest.param({"maximum_cardinality": 0, "pattern": "^A"}, [], False, id="at-most-zero-absent"), + pytest.param({"maximum_cardinality": 0, "pattern": "^A"}, ["A"], True, id="at-most-zero-present"), + ], +) +def test_class_expression_none_of_cardinality(condition, tags, conforms): + """Within none_of, a condition is definitely true or false for an absent + slot only when it decides presence, such as a maximum cardinality of 0; + otherwise it requires the slot in its "definitely true" form.""" + schema = yaml.safe_load(_PRESENCE_SCHEMA) + schema["classes"]["Thing"]["none_of"] = [{"slot_conditions": {"tags": condition}}] + shacl_ttl = ShaclGenerator(yaml.safe_dump(schema), mergeimports=False, closed=False).serialize() + values = "" if not tags else " ; ex:tags " + ", ".join(f'"{t}"' for t in tags) + assert _conforms(shacl_ttl, f"{_CE_PREFIXES}ex:x a ex:Thing{values} .") == conforms + + +@pytest.mark.parametrize( + "condition,properties,expected", + [ + # a cardinality is taken literally inside none_of: "not at most zero values" means present + ("maximum_cardinality: 0", "", False), + ("maximum_cardinality: 0", 'ex:tag "x"', True), + ("maximum_cardinality: 1", 'ex:tag "x"', False), + ("maximum_cardinality: 1", 'ex:tag "x", "y"', True), + ], +) +def test_class_expression_none_of_takes_cardinality_literally(condition, properties, expected): + """Inside none_of only a condition silent on presence and cardinality is made to require the slot.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + f""" +slots: + tag: + multivalued: true +classes: + Thing: + class_uri: ex:Thing + slots: [tag] + none_of: + - slot_conditions: + tag: + {condition} +""" + ) + shacl_ttl = ShaclGenerator(schema, mergeimports=False).serialize() + data = f""" + @prefix ex: . + ex:t a ex:Thing {";" if properties else ""} {properties} . + """ + assert _conforms(shacl_ttl, data) is expected + + +def test_class_expression_equals_string_on_enum_uses_the_meaning(): + """equals_string on an enum slot compares against the permissible value as _add_enum renders it.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +enums: + FormatEnum: + permissible_values: + OSI: + meaning: ex:OSI + MCAP: {} +slots: + fileFormat: + range: FormatEnum + hasChannel: {} +classes: + Format: + class_uri: ex:Format + slots: [fileFormat, hasChannel] + any_of: + - slot_conditions: + fileFormat: + equals_string: OSI + - slot_conditions: + fileFormat: + equals_string: MCAP + hasChannel: + required: true +""" + ) + shacl_ttl = ShaclGenerator(schema, mergeimports=False).serialize() + g = rdflib.Graph().parse(data=shacl_ttl) + first, second = _list_members(g, EX_CE.Format, SH["or"]) + (osi_in,) = g.objects(_condition(g, first, EX_CE.fileFormat), SH["in"]) + assert list(Collection(g, osi_in)) == [EX_CE.OSI] + (mcap_in,) = g.objects(_condition(g, second, EX_CE.fileFormat), SH["in"]) + assert list(Collection(g, mcap_in)) == [Literal("MCAP")] + + prefix = "@prefix ex: ." + assert _conforms(shacl_ttl, f"{prefix} ex:f a ex:Format ; ex:fileFormat ex:OSI .") + assert not _conforms(shacl_ttl, f'{prefix} ex:f a ex:Format ; ex:fileFormat "MCAP" .') + + +@pytest.mark.parametrize("value,expected", [("5.0", True), ("5", True), ("6.0", False)]) +def test_class_expression_equals_number_compares_values(value, expected): + """equals_number matches the value, whatever numeric datatype carries it.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + size: + range: float + label: {} +classes: + Thing: + class_uri: ex:Thing + slots: [size, label] + any_of: + - slot_conditions: + size: + equals_number: 5 + - slot_conditions: + label: + required: true +""" + ) + shacl_ttl = ShaclGenerator(schema, mergeimports=False, closed=False).serialize() + # sh:datatype xsd:float on the class's own property shape would reject an xsd:decimal, + # so every value is given as xsd:float + data = f""" + @prefix ex: . + @prefix xsd: . + ex:t a ex:Thing ; ex:size "{value}"^^xsd:float . + """ + assert _conforms(shacl_ttl, data) is expected + + +_UNTRANSLATABLE_CONDITIONS = { + # an identifier is the node's IRI; here it becomes one only through slot_usage + "identifier": """ +slots: + id: {} + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [id, a] + slot_usage: + id: + identifier: true + any_of: + - slot_conditions: + id: + required: true + - slot_conditions: + a: + required: true +""", + "'undefined', which is not a slot": """ +slots: + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [a] + any_of: + - slot_conditions: + undefined: + required: true + - slot_conditions: + a: + required: true +""", + "does not hold strings": """ +slots: + count: + range: integer + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [count, a] + any_of: + - slot_conditions: + count: + equals_string: "5" + - slot_conditions: + a: + required: true +""", +} + + +@pytest.mark.parametrize("reason", list(_UNTRANSLATABLE_CONDITIONS)) +def test_class_expression_untranslatable_condition_skipped_with_warning(caplog, reason): + """A condition on an identifier, on no slot, or equals_string on a non-string range is not translated.""" + import logging + + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + g = _parse_shacl(_CLASS_EXPRESSION_HEADER + _UNTRANSLATABLE_CONDITIONS[reason]) + + assert (EX_CE.Thing, SH["or"], None) not in g + assert any("any_of" in rec.message and reason in rec.message for rec in caplog.records) + + +def test_class_expression_is_a_with_native_names_uses_sh_node(): + """With native names, is_a references the member class's shape, suffix included, as a range does.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + a: {} +classes: + Marker: + class_uri: ex:Marker + Thing: + class_uri: ex:Thing + slots: [a] + any_of: + - is_a: Marker + - slot_conditions: + a: + required: true +""" + ) + g = _parse_shacl(schema, use_class_uri_names=False, suffix="Shape") + + (first, _) = _list_members(g, EX_CE.ThingShape, SH["or"]) + assert set(g.objects(first, SH.node)) == {EX_CE.MarkerShape} + assert (first, SH["class"], None) not in g + assert (EX_CE.MarkerShape, RDF.type, SH.NodeShape) in g + + +def _condition_property_shapes(g: rdflib.Graph, shape: URIRef, path: URIRef) -> list: + """The property shapes on *path* that the logical constraints of *shape* reach.""" + own = set(g.objects(shape, SH.property)) + return [node for node in g.transitive_objects(shape, None) if (node, SH.path, path) in g and node not in own] + + +def _class_expression_shacl(classes: str, **kwargs) -> rdflib.Graph: + """The SHACL shapes graph of a class-expression schema with the given ``slots:`` / ``classes:`` YAML.""" + return _parse_shacl(_CLASS_EXPRESSION_HEADER + classes, **kwargs) + + +def test_class_expression_condition_title_names_the_property_shape(): + """A condition's title becomes the sh:name of its property shape.""" + g = _class_expression_shacl( + """ +slots: + a: {} + b: {} +classes: + Thing: + class_uri: ex:Thing + slots: [a, b] + any_of: + - slot_conditions: + a: + title: the a condition + required: true + - slot_conditions: + b: + required: true +""" + ) + (condition,) = _condition_property_shapes(g, EX_CE.Thing, EX_CE.a) + assert set(g.objects(condition, SH.name)) == {Literal("the a condition")} + + +@pytest.mark.parametrize("values,conforms", [('"a", "b"', True), ('"a", "b", "c"', False)]) +def test_class_expression_strictest_maximum_applies(values, conforms): + """With an exact and a maximum cardinality, the stricter bound applies.""" + shacl_ttl = _class_expression_shacl( + """ +slots: + tag: + multivalued: true +classes: + Thing: + class_uri: ex:Thing + slots: [tag] + all_of: + - slot_conditions: + tag: + exact_cardinality: 2 + maximum_cardinality: 3 +""" + ).serialize(format="turtle") + assert _conforms(shacl_ttl, f"{_CE_PREFIXES}ex:t a ex:Thing ; ex:tag {values} .") == conforms + + +@pytest.mark.parametrize("ref,conforms", [(None, True), ("ex:s", False), ("ex:o", True)]) +def test_class_expression_none_of_range(ref, conforms): + """A range in a condition is a value constraint: within none_of it requires + the slot in its "definitely true" form, so only a Special value violates.""" + shacl_ttl = _class_expression_shacl( + """ +slots: + ref: + range: Target +classes: + Target: + class_uri: ex:Target + Special: + class_uri: ex:Special + Thing: + class_uri: ex:Thing + slots: [ref] + none_of: + - slot_conditions: + ref: + range: Special +""", + closed=False, + ).serialize(format="turtle") + value = f" ; ex:ref {ref}" if ref else "" + data = f"{_CE_PREFIXES}ex:s a ex:Special, ex:Target . ex:o a ex:Target . ex:t a ex:Thing{value} ." + assert _conforms(shacl_ttl, data) == conforms + + +def test_class_expression_equals_string_uses_the_condition_range(): + """equals_string is rendered for the condition's own range: an enum value + with a meaning becomes that IRI.""" + g = _class_expression_shacl( + """ +enums: + FormatEnum: + permissible_values: + OSI: + meaning: ex:OSI +slots: + fileFormat: {} + other: {} +classes: + Format: + class_uri: ex:Format + slots: [fileFormat, other] + any_of: + - slot_conditions: + fileFormat: + range: FormatEnum + equals_string: OSI + - slot_conditions: + other: + required: true +""" + ) + (condition,) = _condition_property_shapes(g, EX_CE.Format, EX_CE.fileFormat) + in_lists = [list(Collection(g, node)) for node in g.objects(condition, SH["in"])] + in_lists += [ + list(Collection(g, node)) + for and_list in g.objects(condition, SH["and"]) + for member in Collection(g, and_list) + for node in g.objects(member, SH["in"]) + ] + assert in_lists and all(values == [EX_CE.OSI] for values in in_lists) + + +def test_class_expression_equals_string_on_a_custom_string_type(): + """A type derived from string holds strings, so equals_string on it is translated.""" + g = _class_expression_shacl( + """ +types: + Code: + typeof: string +slots: + code: + range: Code + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [code, a] + any_of: + - slot_conditions: + code: + equals_string: AB + - slot_conditions: + a: + required: true +""" + ) + assert (EX_CE.Thing, SH["or"], None) in g + + +def test_class_expression_builtin_range_without_types_import(): + """A built-in range name in a schema that doesn't import linkml:types gives its datatype.""" + schema = """ +id: https://example.org/class-expressions +name: class_expressions +prefixes: + ex: https://example.org/class-expressions/ +default_prefix: ex +slots: + size: {} + a: {} +classes: + Thing: + class_uri: ex:Thing + slots: [size, a] + any_of: + - slot_conditions: + size: + range: integer + - slot_conditions: + a: + required: true +""" + g = _parse_shacl(schema) + (condition,) = _condition_property_shapes(g, EX_CE.Thing, EX_CE.size) + assert (condition, SH.datatype, XSD.integer) in g + + +def test_class_expression_range_with_identifier_is_an_iri(): + """A condition ranged on a class with an identifier expects IRIs, as a slot's range does.""" + g = _class_expression_shacl( + """ +slots: + id: + identifier: true + ref: {} + a: {} +classes: + Target: + class_uri: ex:Target + slots: [id] + Thing: + class_uri: ex:Thing + slots: [ref, a] + any_of: + - slot_conditions: + ref: + range: Target + - slot_conditions: + a: + required: true +""" + ) + (condition,) = _condition_property_shapes(g, EX_CE.Thing, EX_CE.ref) + assert set(g.objects(condition, SH.nodeKind)) == {SH.IRI} + + +_UNTRANSLATABLE_MEMBERS = { + "untranslatable-second-member": ( + "has_member", + """ + - slot_conditions: + a: + required: true + - slot_conditions: + tags: + has_member: + equals_string: x""", + ), + "unknown-condition-range": ( + "unknown range", + """ + - slot_conditions: + a: + range: Undefined + - slot_conditions: + b: + required: true""", + ), + "untranslatable-nested-member": ( + "has_member", + """ + - all_of: + - slot_conditions: + tags: + has_member: + equals_string: x + - slot_conditions: + a: + required: true""", + ), + "is_a-not-a-class": ( + "not a class", + """ + - is_a: Undefined + - slot_conditions: + a: + required: true""", + ), + "equals_string-on-class-range": ( + "does not hold strings", + """ + - slot_conditions: + ref: + equals_string: x + - slot_conditions: + a: + required: true""", + ), + "equals_string_in-on-integer": ( + "does not hold strings", + """ + - slot_conditions: + count: + equals_string_in: ["5"] + - slot_conditions: + a: + required: true""", + ), +} + + +@pytest.mark.parametrize("case", list(_UNTRANSLATABLE_MEMBERS)) +def test_class_expression_untranslatable_member_skips_the_operator(caplog, case): + """An operator with an untranslatable member anywhere, at any depth or + position, is skipped as a whole, with a warning naming the reason.""" + reason, members = _UNTRANSLATABLE_MEMBERS[case] + schema = ( + _CLASS_EXPRESSION_HEADER + + """ +slots: + tags: + multivalued: true + ref: + range: Target + count: + range: integer + a: {} + b: {} +classes: + Target: + class_uri: ex:Target + Thing: + class_uri: ex:Thing + slots: [tags, ref, count, a, b] + any_of:""" + + members + + "\n" + ) + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + g = _parse_shacl(schema) + assert (EX_CE.Thing, SH["or"], None) not in g + assert any("any_of" in rec.message and reason in rec.message for rec in caplog.records), caplog.text + + +_REPEATED_PARAMETERS_SCHEMA = ( + _CLASS_EXPRESSION_HEADER + + """ +types: + Code: + typeof: string + pattern: "^[A-Z]+$" +enums: + LetterEnum: + permissible_values: + A: {} + B: {} + C: {} +slots: + size: + range: integer + label: {} + letter: {} + code: {} + other: {} +classes: + Thing: + class_uri: ex:Thing + slots: [size, label, letter, code, other] + any_of: + - slot_conditions: + size: + minimum_value: 3 + equals_number: 5 + label: + equals_string: A + equals_string_in: [A, B] + letter: + range: LetterEnum + equals_string: B + code: + range: Code + pattern: "^AB" + - slot_conditions: + other: + required: true +""" +) + + +@pytest.mark.parametrize( + "properties,expected", + [ + ('ex:size 5 ; ex:label "A" ; ex:letter "B" ; ex:code "ABC"', True), + ("ex:size 4", False), + ('ex:label "B"', False), + ('ex:letter "A"', False), + ('ex:code "ABc"', False), + ('ex:code "XY"', False), + ('ex:size 4 ; ex:other "x"', True), + ], +) +def test_class_expression_repeated_parameters_stay_well_formed(properties, expected): + """A parameter SHACL allows once per shape, needed twice by one condition, goes into sh:and. + + _conforms runs pyshacl with meta_shacl, which fails on an ill-formed shapes graph. + """ + shacl_ttl = ShaclGenerator(_REPEATED_PARAMETERS_SCHEMA, mergeimports=False).serialize() + data = f""" + @prefix ex: . + ex:t a ex:Thing ; {properties} . + """ + assert _conforms(shacl_ttl, data) is expected + + +@pytest.mark.parametrize( + "extra,properties,expected", + [ + ("", "", True), + ("maximum_cardinality: 5", "", True), + ("minimum_cardinality: 0", "", True), + ("maximum_cardinality: 5", 'ex:tag "A"', False), + ("maximum_cardinality: 5", 'ex:tag "B"', True), + ], +) +def test_class_expression_none_of_presence_is_monotonic(extra, properties, expected): + """Inside none_of, a cardinality that an absent slot satisfies does not flip an absent slot to rejected.""" + schema = ( + _CLASS_EXPRESSION_HEADER + + f""" +slots: + tag: + multivalued: true +classes: + Thing: + class_uri: ex:Thing + slots: [tag] + none_of: + - slot_conditions: + tag: + equals_string: A + {extra} +""" + ) + shacl_ttl = ShaclGenerator(schema, mergeimports=False).serialize() + data = f""" + @prefix ex: . + ex:t a ex:Thing {";" if properties else ""} {properties} . + """ + assert _conforms(shacl_ttl, data) is expected diff --git a/tests/linkml/test_validator/test_validation_context.py b/tests/linkml/test_validator/test_validation_context.py index a674048952..e19c1bda48 100644 --- a/tests/linkml/test_validator/test_validation_context.py +++ b/tests/linkml/test_validator/test_validation_context.py @@ -647,9 +647,9 @@ def _schema_with_value_disallowed() -> SchemaDefinition: def test_cache_hit_preserves_value_disallowed_not_keyword(): - """A slot with value_presence=ABSENT produces a root-level `not` keyword. - Cache-hit must carry it through from defs_class so the validator rejects - instances that include the forbidden field.""" + """A slot with value_presence=ABSENT produces a root-level `not: {required}` + in `allOf`. Cache-hit must carry it through from defs_class so the + validator rejects instances that include the forbidden field.""" schema = _schema_with_value_disallowed() cold = ValidationContext(schema, "ForbidsX").json_schema_validator( @@ -661,8 +661,9 @@ def test_cache_hit_preserves_value_disallowed_not_keyword(): closed=False, include_range_class_descendants=False ) - assert "not" in cold.schema, cold.schema - assert "not" in warm.schema, "value_disallowed `not` keyword must survive cache-hit" + forbids = {"not": {"required": ["forbidden_field"]}} + assert forbids in cold.schema.get("allOf", []), cold.schema + assert forbids in warm.schema.get("allOf", []), "value_disallowed `not` keyword must survive cache-hit" # Instance includes the forbidden field -> both validators reject bad = {"forbidden_field": "anything", "other_field": "ok"} @@ -677,18 +678,19 @@ def test_cache_hit_preserves_value_disallowed_not_keyword(): def test_cache_hit_does_not_leak_value_disallowed_not_to_target_without(): - """Warming with ForbidsX places `not` at root in cached. Hitting cache for - Plain (which has no value_disallowed slots) must NOT inherit `not`.""" + """Warming with ForbidsX places `not: {required}` at root in cached. Hitting + cache for Plain (which has no value_disallowed slots) must NOT inherit it.""" schema = _schema_with_value_disallowed() cache_key = _make_cache_key(schema, include_range_class_descendants=False) ValidationContext(schema, "ForbidsX").json_schema_validator(closed=False, include_range_class_descendants=False) - assert "not" in _json_schema_cache[cache_key] + forbids = {"not": {"required": ["forbidden_field"]}} + assert forbids in _json_schema_cache[cache_key].get("allOf", []) validator = ValidationContext(schema, "Plain").json_schema_validator( closed=False, include_range_class_descendants=False ) - assert "not" not in validator.schema, validator.schema.get("not") + assert forbids not in validator.schema.get("allOf", []), validator.schema.get("allOf") # Plain has no constraint against `forbidden_field` — the leak would falsely # reject this. Confirm it doesn't. From 55124e1d97a3c1b6153aee734210fba061c5e935 Mon Sep 17 00:00:00 2001 From: Carlo van Driesten Date: Fri, 2 Oct 2026 13:23:41 +0200 Subject: [PATCH 22/30] feat(jsonschemagen): emit propertyNames from inlined-dict key slot constraints For an inlined-as-dict slot whose range class has an identifier/key slot, render the key slot's string-applicable constraints onto JSON Schema propertyNames (draft-06+) instead of dropping them. In the inlined-dict form the mapping key is the identifier value, so the key slot's constraints constrain the keys. JSON object keys are always strings, so only pattern, enum (equals_string_in) and a string const (equals_string) are emitted; numeric minimum/maximum, numeric const (equals_number) and allOf are excluded -- a numeric const would otherwise reject every key. structured_pattern is honored when materialize_patterns is enabled, consistent with value patterns. Backward compatible: emitted only when a string-applicable key constraint applies. Signed-off-by: Carlo van Driesten Includes the documentation for the emitted propertyNames constraint. --- docs/generators/json-schema.rst | 66 +++++++ .../src/linkml/generators/jsonschemagen.py | 39 +++++ .../test_generators/test_jsonschemagen.py | 162 ++++++++++++++++++ 3 files changed, 267 insertions(+) diff --git a/docs/generators/json-schema.rst b/docs/generators/json-schema.rst index bfc23e6558..9515b76dfc 100644 --- a/docs/generators/json-schema.rst +++ b/docs/generators/json-schema.rst @@ -385,6 +385,72 @@ will generate: LinkML also supports `Structured patterns `_, these are compiled down to patterns during JSON Schema generation. +Dictionary key constraints (propertyNames) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +A multivalued, inlined slot whose range class has an identifier slot is +compiled to a JSON object keyed by that identifier (see *Inlining* above). +When the identifier slot carries string-applicable constraints, they are +emitted as a `propertyNames `_ +schema on the container object, so the *keys* of the dictionary are validated, +not just the values: + +.. code-block:: yaml + + slots: + tags: + range: Tag + multivalued: true + inlined: true + uid: + identifier: true + pattern: "^(0|[1-9][0-9]*)$" + +generates on the container: + +.. code-block:: json + + "tags": { + "additionalProperties": {"$ref": "#/$defs/Tag"}, + "propertyNames": {"pattern": "^(0|[1-9][0-9]*)$"}, + "type": "object" + } + +The constraints carried over from the key slot are the ones applicable to JSON +Schema strings, because object keys are always strings (`JSON Schema Core +2019-09, §9.3.2.5 `_): + +* ``pattern`` -- whether written directly on the slot, resolved from a + ``structured_pattern``, or inherited from the slot's ``range`` type (for + example an identifier with ``range: ncname``, or a user-defined type that + declares a ``pattern``); +* ``equals_string_in``, emitted as ``enum``; +* a string ``equals_string``, emitted as ``const``. + +The emitted key pattern is always the same one that applies to the identifier +*inside* the value object, so a key and a redundantly repeated in-object +identifier are now validated identically. + +Numeric constraints -- ``minimum_value``/``maximum_value``, and the numeric +``const`` produced by ``equals_number`` -- are deliberately **not** carried +over: they cannot be satisfied by a string key, and a numeric ``const`` would +reject every key. The ``allOf`` produced by a ``range_expression``, and the +permissible values of an ``enum``-ranged identifier, are likewise out of scope. + +``propertyNames`` composes conjunctively with ``additionalProperties``, so keys +and values are constrained independently. It is emitted only when the key slot +actually carries one of the constraints listed above; an unconstrained key slot +produces exactly the same output as before. + +.. note:: + + Because type-level patterns are included, an identifier slot whose range is + ``ncname`` (or another pattern-bearing type) gains a ``propertyNames`` + entry even if the slot itself declares no constraint. The generated schema + becomes stricter, but only in ways the model already required: data whose + keys satisfy the declared identifier type is unaffected. + + Rules ^^^^^ diff --git a/packages/linkml/src/linkml/generators/jsonschemagen.py b/packages/linkml/src/linkml/generators/jsonschemagen.py index 67770ffc65..b3fdb2ed49 100644 --- a/packages/linkml/src/linkml/generators/jsonschemagen.py +++ b/packages/linkml/src/linkml/generators/jsonschemagen.py @@ -919,6 +919,40 @@ def get_value_constraints_for_slot(self, slot: SlotDefinition | AnonymousSlotExp return constraints + def get_key_constraints_for_slot(self, slot: SlotDefinition | None) -> JsonSchema: + """Constraints applicable to the *keys* of an inlined-as-dict slot. + + In the inlined-dict form the mapping key *is* the value of the range class's + identifier/key slot (https://linkml.io/linkml/schemas/inlining.html) and is not + repeated inside the value object, so constraints declared on that slot -- or + inherited from its type -- are constraints on the object keys. The result is + intended for JSON Schema ``propertyNames``, which composes conjunctively with + ``additionalProperties``. + + JSON object keys are always strings (JSON Schema Core 2019-09, 9.3.2.5), so only + the string-applicable subset of :meth:`get_value_constraints_for_slot` is + returned: ``pattern`` (including a resolved ``structured_pattern`` and a pattern + inherited from the slot's type), a string ``const`` (``equals_string``) and a + string ``enum`` (``equals_string_in``). Numeric constraints -- ``minimum`` and + ``maximum``, and the numeric ``const`` produced by ``equals_number`` -- and the + ``allOf`` produced by ``range_expression`` are excluded: they cannot be satisfied + by a string key, and a numeric ``const`` would reject *every* key. + + :param slot: the identifier or key slot of the range class + :return: a schema for ``propertyNames``; empty when the key is unconstrained + """ + constraints = self.get_value_constraints_for_slot(slot) + + key_constraints = JsonSchema() + for keyword in ("pattern", "const"): + value = constraints.get(keyword) + if isinstance(value, str): + key_constraints[keyword] = value + enum_values = constraints.get("enum") + if isinstance(enum_values, list) and all(isinstance(value, str) for value in enum_values): + key_constraints["enum"] = enum_values + return key_constraints + def get_subschema_for_slot( self, slot: SlotDefinition | AnonymousSlotExpression, @@ -975,6 +1009,11 @@ def get_subschema_for_slot( else: typ = ["object", "null"] prop = JsonSchema({"type": typ, "additionalProperties": additionalProps}) + # The dict keys are the range's identifier/key values, so that + # slot's string-applicable constraints constrain the keys. + key_constraints = self.get_key_constraints_for_slot(range_id_slot) + if key_constraints: + prop["propertyNames"] = key_constraints self.top_level_schema.add_lax_def(reference, self.aliased_slot_name(range_id_slot)) else: prop = JsonSchema.array_of(JsonSchema.ref_for(reference), include_null, required=slot.required) diff --git a/tests/linkml/test_generators/test_jsonschemagen.py b/tests/linkml/test_generators/test_jsonschemagen.py index b3fd57baa1..1acc8457ea 100644 --- a/tests/linkml/test_generators/test_jsonschemagen.py +++ b/tests/linkml/test_generators/test_jsonschemagen.py @@ -1967,3 +1967,165 @@ def test_class_expression_condition_uses_the_induced_slot(tags, valid): """ json_schema = json.loads(JsonSchemaGenerator(schema, top_class="Thing").serialize()) assert jsonschema.Draft7Validator(json_schema).is_valid({"tags": tags}) == valid + + +def _inlined_dict_schema( + key_slot_yaml: str, + key_decl: str = "identifier: true", + key_range: str = "string", + extra_yaml: str = "", +) -> str: + """Build a schema with an inlined-as-dict slot whose key slot is configured by + ``key_decl`` (``identifier: true`` or ``key: true``), ``key_range`` (the key slot + range), and ``key_slot_yaml`` (extra YAML lines for the key slot). ``extra_yaml`` is + appended at the top level, for declaring extra ``types``/``enums``.""" + return f""" +id: https://example.org/test-key-constraints +name: test-key-constraints +prefixes: + linkml: https://w3id.org/linkml/ +default_range: string +imports: + - linkml:types +{extra_yaml} +classes: + Container: + tree_root: true + attributes: + entries: + range: Entry + multivalued: true + inlined: true + inlined_as_list: false + Entry: + attributes: + key: + {key_decl} + range: {key_range} +{key_slot_yaml} + val: + range: string +""" + + +@pytest.mark.parametrize("key_decl", ["identifier: true", "key: true"]) +def test_inlined_dict_key_pattern_emits_property_names(key_decl): + """A literal ``pattern`` on the inlined-dict key slot (identifier or key) must be + rendered onto ``propertyNames``.""" + schema = _inlined_dict_schema(' pattern: "^[0-9]+$"', key_decl=key_decl) + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert generated["properties"]["entries"]["propertyNames"] == {"pattern": "^[0-9]+$"} + + +def test_inlined_dict_key_enum_emits_property_names(): + """``equals_string_in`` on the key slot becomes an ``enum`` constraint on keys.""" + schema = _inlined_dict_schema(" equals_string_in:\n - a\n - b") + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert generated["properties"]["entries"]["propertyNames"] == {"enum": ["a", "b"]} + + +def test_inlined_dict_no_key_constraint_emits_no_property_names(): + """No constraint on the key slot -> no ``propertyNames`` (unchanged behavior).""" + schema = _inlined_dict_schema("") + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert "propertyNames" not in generated["properties"]["entries"] + + +def test_inlined_dict_key_structured_pattern_emits_property_names(): + """``structured_pattern`` on the key slot is resolved and rendered onto + ``propertyNames`` -- identical to how value patterns are handled.""" + schema = _inlined_dict_schema(" structured_pattern:\n syntax: '[0-9]+'") + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + + key_pattern = generated["$defs"]["Entry"]["properties"]["key"]["pattern"] + assert generated["properties"]["entries"]["propertyNames"] == {"pattern": key_pattern} + jsonschema.validate({"entries": {"12": {"val": "x"}}}, generated) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({"entries": {"bad-key": {"val": "x"}}}, generated) + + +def test_inlined_dict_property_names_rejects_nonmatching_keys(): + """Behavioral check: keys matching the pattern validate; non-matching keys fail.""" + schema = _inlined_dict_schema(' pattern: "^[0-9]+$"') + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + + jsonschema.validate({"entries": {"0": {"val": "x"}}}, generated) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({"entries": {"bad-key": {"val": "x"}}}, generated) + + +def test_inlined_dict_key_string_const_emits_property_names(): + """A string ``const`` (``equals_string``) on the key slot becomes a key const.""" + schema = _inlined_dict_schema(" equals_string: fixed") + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert generated["properties"]["entries"]["propertyNames"] == {"const": "fixed"} + jsonschema.validate({"entries": {"fixed": {"val": "x"}}}, generated) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate({"entries": {"other": {"val": "x"}}}, generated) + + +def test_inlined_dict_key_numeric_const_is_not_emitted(): + """A numeric ``const`` (``equals_number``) must NOT be emitted onto propertyNames: + keys are always strings, so a numeric const would reject every key. The keys are + left unconstrained instead.""" + schema = _inlined_dict_schema(" equals_number: 5", key_range="integer") + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert "propertyNames" not in generated["properties"]["entries"] + # numeric-looking string keys still validate (unconstrained) + jsonschema.validate({"entries": {"5": {"val": "x"}}}, generated) + jsonschema.validate({"entries": {"anything": {"val": "x"}}}, generated) + + +def test_inlined_dict_key_numeric_bounds_are_not_emitted(): + """Numeric ``minimum``/``maximum`` on the key slot are no-ops on string keys and + must not be emitted (they would be misleading clutter).""" + schema = _inlined_dict_schema(" minimum_value: 1\n maximum_value: 10", key_range="integer") + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert "propertyNames" not in generated["properties"]["entries"] + + +@pytest.mark.parametrize( + ("key_range", "extra_yaml"), + [ + ("ncname", ""), + ("DigitString", "types:\n DigitString:\n typeof: string\n pattern: '^[0-9]+$'"), + ], + ids=["base-implied-pattern", "user-defined-type-pattern"], +) +def test_inlined_dict_key_type_pattern_emits_property_names(key_range, extra_yaml): + """A pattern inherited from the key slot's *type* constrains the identifier value just + as a slot-level pattern does, so it must reach ``propertyNames`` too. The emitted key + pattern is exactly the one applied to the identifier inside the value object, so keys + and the (optional) in-object identifier are validated identically.""" + schema = _inlined_dict_schema("", key_range=key_range, extra_yaml=extra_yaml) + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + + value_key_pattern = generated["$defs"]["Entry__identifier_optional"]["properties"]["key"]["pattern"] + assert generated["properties"]["entries"]["propertyNames"] == {"pattern": value_key_pattern} + + +def test_inlined_dict_key_enum_range_emits_no_property_names(): + """A key slot whose range is a LinkML *enum* is compiled to a ``$ref`` on the value + side; ``get_value_constraints_for_slot`` reports no string-applicable constraint for + it, so no ``propertyNames`` is emitted and the keys stay unconstrained.""" + schema = _inlined_dict_schema( + "", key_range="Colour", extra_yaml="enums:\n Colour:\n permissible_values:\n red:\n green:" + ) + generated = json.loads(JsonSchemaGenerator(schema).serialize()) + assert "propertyNames" not in generated["properties"]["entries"] + + +def test_inlined_dict_key_constraints_helper_drops_non_string_values(): + """``get_key_constraints_for_slot`` keeps only string-applicable keywords, regardless + of which upstream constraint produced them: a numeric ``const`` or a non-string + ``enum`` would reject every key, so both are dropped.""" + schema = _inlined_dict_schema("") + generator = JsonSchemaGenerator(schema) + generator.generate() + + slot = SlotDefinition("key", pattern="^[0-9]+$") + assert generator.get_key_constraints_for_slot(slot) == {"pattern": "^[0-9]+$"} + + assert generator.get_key_constraints_for_slot(SlotDefinition("key", equals_number=5)) == {} + assert generator.get_key_constraints_for_slot(SlotDefinition("key", minimum_value=1)) == {} + assert generator.get_key_constraints_for_slot(None) == {} From bd2826516c2b7166774d54c11043d435b1bda8ff Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 2 Oct 2026 13:22:53 +0200 Subject: [PATCH 23/30] feat(generators): add --normalize-prefixes flag for well-known prefix names Add an opt-in --normalize-prefixes flag to OWL, SHACL, and JSON-LD Context generators that normalises non-standard prefix aliases to well-known names from a static prefix map (derived from rdflib 7.x defaults, cross-checked against prefix.cc consensus). Key design decisions: - Static frozen map (MappingProxyType) instead of runtime Graph().namespaces() lookup eliminates rdflib version dependency - Both http://schema.org/ and https://schema.org/ map to 'schema' - Shared normalize_graph_prefixes() helper used by OWL and SHACL - Two-phase graph normalisation: Phase 1 normalises schema-declared prefixes, Phase 2 cleans up runtime-injected bindings - Collision detection: skip with warning when standard prefix name is already user-declared for a different namespace - Phase 2 guard prevents overwriting HTTPS bindings with HTTP variants The flag defaults to off, preserving existing behaviour. Includes the documentation for the flag in docs/generators/owl.rst. --- docs/generators/owl.rst | 20 + packages/linkml/pyproject.toml | 9 +- .../src/linkml/generators/jsonldcontextgen.py | 82 ++- .../linkml/src/linkml/generators/jsonldgen.py | 2 + .../linkml/src/linkml/generators/owlgen.py | 6 +- .../linkml/src/linkml/generators/shaclgen.py | 6 +- packages/linkml/src/linkml/utils/generator.py | 170 +++++- .../test_generators/test_jsonldcontextgen.py | 115 ++++ .../test_normalize_prefixes.py | 545 ++++++++++++++++++ uv.lock | 10 +- 10 files changed, 952 insertions(+), 13 deletions(-) create mode 100644 tests/linkml/test_generators/test_normalize_prefixes.py diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst index 87b0f402c4..a87f7a6c70 100644 --- a/docs/generators/owl.rst +++ b/docs/generators/owl.rst @@ -67,6 +67,26 @@ Mapping .. note:: The current default settings for ``metaclasses`` and ``type-objects`` may change in the future +Prefix normalization +^^^^^^^^^^^^^^^^^^^^ + +Schemas sometimes declare non-standard aliases for well-known namespaces +(e.g. ``sh1:`` for the SHACL namespace, or a versioned alias for ``skos:``). +By default these aliases are carried through into the generated artifact. + +Use ``--normalize-prefixes`` to remap declared prefixes whose namespace IRI +matches a well-known vocabulary to that vocabulary's conventional name in the +output (``owl``, ``rdf``, ``rdfs``, ``skos``, ``sh``, ``xsd``, ...): + +.. code:: bash + + gen-owl --normalize-prefixes schema.yaml + +The mapping is a static, version-independent table; namespace IRIs that are +not in the table are left untouched. The option is also available on +``gen-shacl`` and ``gen-jsonld-context``. + + Enums and PermissibleValues ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/packages/linkml/pyproject.toml b/packages/linkml/pyproject.toml index 2369602bd4..30012d73ae 100644 --- a/packages/linkml/pyproject.toml +++ b/packages/linkml/pyproject.toml @@ -50,7 +50,10 @@ dependencies = [ # Specifier syntax: https://peps.python.org/pep-0631/ "openpyxl", "parse", "prefixcommons >= 0.1.7", - "prefixmaps >= 0.2.2", + # TODO(prefixmaps-0.2.8): Replace git pin with "prefixmaps >= 0.2.8" once released, + # then remove [tool.hatch.metadata] allow-direct-references and regenerate uv.lock. + # Tracked in: https://github.com/linkml/prefixmaps/issues/82 + "prefixmaps @ git+https://github.com/linkml/prefixmaps@75435150a1b31760b9780af2b64a265943a9b263", "pydantic>=2.13.5,<3.0.0", "pyjsg >= 0.12.3", "pyshex >= 0.9.0", @@ -211,6 +214,10 @@ vcs = "git" style = "pep440" fallback-version = "0.0.0" +[tool.hatch.metadata] +# TODO(prefixmaps-0.2.8): Remove this section once the git pin is replaced with >= 0.2.8 +allow-direct-references = true + [tool.hatch.version] source = "uv-dynamic-versioning" diff --git a/packages/linkml/src/linkml/generators/jsonldcontextgen.py b/packages/linkml/src/linkml/generators/jsonldcontextgen.py index 95c9177f82..56ed6a011b 100644 --- a/packages/linkml/src/linkml/generators/jsonldcontextgen.py +++ b/packages/linkml/src/linkml/generators/jsonldcontextgen.py @@ -15,7 +15,7 @@ from linkml._version import __version__ from linkml.utils.deprecation import deprecated_fields -from linkml.utils.generator import Generator, shared_arguments +from linkml.utils.generator import Generator, shared_arguments, well_known_prefix_map from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, SlotDefinition from linkml_runtime.linkml_model.types import SHEX from linkml_runtime.utils.formatutils import camelcase, underscore @@ -93,6 +93,9 @@ class ContextGenerator(Generator): frame_root: str | None = None def __post_init__(self) -> None: + # Must be set before super().__post_init__() because the parent triggers + # the visitor pattern (visit_schema), which accesses _prefix_remap. + self._prefix_remap: dict[str, str] = {} super().__post_init__() if self.namespaces is None: raise TypeError("Schema text must be supplied to context generator. Preparsed schema will not work") @@ -130,8 +133,14 @@ def _collect_external_elements(sv: SchemaView) -> tuple[set[str], set[str]]: external_slots.update(schema_def.slots.keys()) return external_classes, external_slots + def add_prefix(self, ncname: str) -> None: + """Add a prefix, applying well-known prefix normalisation when enabled.""" + super().add_prefix(self._prefix_remap.get(ncname, ncname)) + def visit_schema(self, base: str | Namespace | None = None, output: str | None = None, **_): - # Add any explicitly declared prefixes + # Add any explicitly declared prefixes. + # Direct .add() is safe here: the normalisation block below explicitly + # rewrites emit_prefixes entries for any renamed prefixes (Cases 1-3). for prefix in self.schema.prefixes.values(): self.emit_prefixes.add(prefix.prefix_prefix) @@ -139,6 +148,68 @@ def visit_schema(self, base: str | Namespace | None = None, output: str | None = for pfx in self.schema.emit_prefixes: self.add_prefix(pfx) + # Normalise well-known prefix names when --normalize-prefixes is set. + # If the schema declares a non-standard alias for a namespace that has + # a well-known standard name (e.g. ``sdo`` for + # ``https://schema.org/``), replace the alias with the standard name + # so that generated JSON-LD contexts use the conventional prefix. + # + # Three cases are handled: + # 1. Standard prefix is not yet bound → just rebind from old to new. + # 2. Standard prefix is bound to a *different* URI: + # a. User-declared (in schema.prefixes) → collision, skip with warning. + # b. Runtime default (e.g. linkml-runtime's ``schema: http://…``) + # → remove stale binding, then rebind. + # 3. Standard prefix is already bound to the *same* URI (duplicate) + # → just drop the non-standard alias. + # + # A remap dict is stored for ``_build_element_id`` because + # ``prefix_suffix()`` splits CURIEs on ``:`` without looking up the + # namespace dict. + self._prefix_remap.clear() + if self.normalize_prefixes: + wk = well_known_prefix_map() + for old_pfx in list(self.namespaces): + url = str(self.namespaces[old_pfx]) + std_pfx = wk.get(url) + if not std_pfx or std_pfx == old_pfx: + continue + if std_pfx in self.namespaces: + if str(self.namespaces[std_pfx]) != url: + # Case 2: std_pfx is bound to a different URI. + # If the user explicitly declared std_pfx in the schema, + # it is intentional — skip to avoid data loss. + if std_pfx in self.schema.prefixes: + self.logger.warning( + "Prefix collision: cannot rename '%s' to '%s' because '%s' is " + "already declared for <%s>; skipping normalisation for <%s>", + old_pfx, + std_pfx, + std_pfx, + str(self.namespaces[std_pfx]), + url, + ) + continue + # Not user-declared (e.g. linkml-runtime default) — safe to remove + self.emit_prefixes.discard(std_pfx) + del self.namespaces[std_pfx] + else: + # Case 3: standard prefix already bound to same URI + # — just drop the non-standard alias + del self.namespaces[old_pfx] + if old_pfx in self.emit_prefixes: + self.emit_prefixes.discard(old_pfx) + self.emit_prefixes.add(std_pfx) + self._prefix_remap[old_pfx] = std_pfx + continue + # Case 1 (or Case 2 after stale removal): bind standard name + self.namespaces[std_pfx] = self.namespaces[old_pfx] + del self.namespaces[old_pfx] + if old_pfx in self.emit_prefixes: + self.emit_prefixes.discard(old_pfx) + self.emit_prefixes.add(std_pfx) + self._prefix_remap[old_pfx] = std_pfx + # Add the default prefix if self.schema.default_prefix: dflt = self.namespaces.prefix_for(self.schema.default_prefix) @@ -146,6 +217,8 @@ def visit_schema(self, base: str | Namespace | None = None, output: str | None = self.default_ns = dflt if self.default_ns: default_uri = self.namespaces[self.default_ns] + # Direct .add() is safe: default_ns is already resolved from + # the (possibly normalised) namespace bindings above. self.emit_prefixes.add(self.default_ns) else: default_uri = self.schema.default_prefix @@ -514,6 +587,11 @@ def _build_element_id(self, definition: Any, uri: str) -> None: @return: None """ uri_prefix, uri_suffix = self.namespaces.prefix_suffix(uri) + # Apply well-known prefix normalisation (e.g. sdo → schema). + # prefix_suffix() splits CURIEs on ':' without checking the + # namespace dict, so it may return a stale alias. + if uri_prefix and uri_prefix in self._prefix_remap: + uri_prefix = self._prefix_remap[uri_prefix] is_default_namespace = uri_prefix == self.context_body["@vocab"] or uri_prefix == self.namespaces.prefix_for( self.context_body["@vocab"] ) diff --git a/packages/linkml/src/linkml/generators/jsonldgen.py b/packages/linkml/src/linkml/generators/jsonldgen.py index 1de32d7414..3b362e0cc3 100644 --- a/packages/linkml/src/linkml/generators/jsonldgen.py +++ b/packages/linkml/src/linkml/generators/jsonldgen.py @@ -196,6 +196,8 @@ def end_schema( # directory twice. context_kwargs.setdefault("importmap", self.importmap) context_kwargs.setdefault("base_dir", self.original_base_dir) + # Forward prefix normalisation into the inline @context. + context_kwargs.setdefault("normalize_prefixes", self.normalize_prefixes) add_prefixes = ContextGenerator(self.original_schema, **context_kwargs).serialize() add_prefixes_json = loads(add_prefixes) metamodel_ctx = self.metamodel_context or METAMODEL_CONTEXT_URI diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 3f16c4965c..ec1a11baab 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -21,7 +21,7 @@ from linkml._version import __version__ from linkml.generators.common.subproperty import is_xsd_anyuri_range from linkml.utils.deprecation import deprecation_warning -from linkml.utils.generator import Generator, shared_arguments +from linkml.utils.generator import Generator, normalize_graph_prefixes, shared_arguments from linkml.utils.language_tags import LanguageTagResolver from linkml_runtime import SchemaView from linkml_runtime.linkml_model.meta import ( @@ -319,6 +319,10 @@ def as_graph(self) -> Graph: self.graph.bind(prefix, self.metamodel.namespaces[prefix]) for pfx in schema.prefixes.values(): self.graph.namespace_manager.bind(pfx.prefix_prefix, URIRef(pfx.prefix_reference)) + if self.normalize_prefixes: + normalize_graph_prefixes( + graph, {str(v.prefix_prefix): str(v.prefix_reference) for v in schema.prefixes.values()} + ) graph.add((base, RDF.type, OWL.Ontology)) # Add main schema elements diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 59b5204520..1f6e3f6255 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -17,7 +17,7 @@ from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range from linkml.generators.shacl.shacl_data_type import ShaclDataType from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor -from linkml.utils.generator import Generator, shared_arguments +from linkml.utils.generator import Generator, normalize_graph_prefixes, shared_arguments from linkml.utils.language_tags import LanguageTagResolver from linkml_runtime.linkml_model.meta import ( AnonymousClassExpression, @@ -262,6 +262,10 @@ def as_graph(self) -> Graph: for pfx in self.schema.prefixes.values(): g.bind(str(pfx.prefix_prefix), pfx.prefix_reference) + if self.normalize_prefixes: + normalize_graph_prefixes( + g, {str(v.prefix_prefix): str(v.prefix_reference) for v in self.schema.prefixes.values()} + ) self._class_expressions_added: set[tuple[URIRef, str, str]] = set() self._class_expression_problems: dict[tuple[str, str, str], list[str]] = {} diff --git a/packages/linkml/src/linkml/utils/generator.py b/packages/linkml/src/linkml/utils/generator.py index 8ea9e28da9..31e3bfa168 100644 --- a/packages/linkml/src/linkml/utils/generator.py +++ b/packages/linkml/src/linkml/utils/generator.py @@ -20,13 +20,14 @@ import os import re import sys +import types import warnings from collections.abc import Callable, Mapping from copy import deepcopy from dataclasses import dataclass, field from functools import lru_cache from pathlib import Path -from typing import IO, Any, ClassVar, TextIO, Union, cast +from typing import IO, TYPE_CHECKING, Any, ClassVar, TextIO, Union, cast import click import yaml @@ -60,6 +61,9 @@ from linkml_runtime.utils.formatutils import camelcase, underscore from linkml_runtime.utils.namespaces import Namespaces +if TYPE_CHECKING: + from rdflib import Graph + logger = logging.getLogger(__name__) @@ -80,6 +84,154 @@ def _resolved_metamodel(mergeimports): return metamodel +def well_known_prefix_map() -> dict[str, str]: + """Return a mapping from namespace URI to standard prefix name. + + Primary source: the ``linked_data`` context from `prefixmaps + `_ — the canonical curated + registry maintained by the LinkML team. This context provides + correct, community-consensus prefix names (e.g. ``sh`` not ``shacl``, + ``schema`` not ``sdo``). + + Secondary source: the ``merged`` context from prefixmaps, which + combines prefix.cc, bioregistry, and other sources for broad coverage. + + A small ``_PREFIX_OVERRIDES`` map corrects the few cases where the + merged context disagrees with rdflib/W3C canonical names. + + Both ``http`` and ``https`` variants of schema.org and wgs84 are + included because the linkml-runtime historically binds the HTTP form + while rdflib (and the W3C) prefer HTTPS. + + .. note:: + Requires ``prefixmaps >= 0.2.7``. For entries added in + linkml/prefixmaps#81 (W3C/OGC standard prefixes), pin to + ``prefixmaps @ git+https://github.com/linkml/prefixmaps@75435150`` + until v0.2.8 is released. + """ + return dict(_cached_well_known_prefix_map()) + + +@lru_cache(maxsize=1) +def _cached_well_known_prefix_map() -> dict[str, str]: + """Internal cached builder for well_known_prefix_map().""" + from prefixmaps import load_context + + # Layer 1: merged context (broad coverage, first-seen-wins for duplicates). + merged = load_context("merged") + ns_to_prefix: dict[str, str] = {} + for rec in merged.prefix_expansions: + if rec.namespace not in ns_to_prefix: + ns_to_prefix[rec.namespace] = rec.prefix + + # Layer 2: linked_data context (curated, correct names) overrides merged. + ld = load_context("linked_data") + for rec in ld.prefix_expansions: + ns_to_prefix[rec.namespace] = rec.prefix + + # Layer 3: overrides for the few cases where merged/linked_data disagrees + # with the rdflib/W3C canonical forms used by the RDF community. + for ns, pfx in _PREFIX_OVERRIDES.items(): + ns_to_prefix[ns] = pfx + + # Ensure both HTTP/HTTPS schema.org variants resolve to 'schema'. + ns_to_prefix.setdefault("https://schema.org/", "schema") + ns_to_prefix["http://schema.org/"] = "schema" + + # Ensure both HTTP/HTTPS wgs84 variants resolve to 'wgs'. + ns_to_prefix.setdefault("https://www.w3.org/2003/01/geo/wgs84_pos#", "wgs") + + return ns_to_prefix + + +# Overrides: corrections where prefixmaps merged context uses non-standard names +# that differ from rdflib 7.x / W3C canonical forms. +_PREFIX_OVERRIDES: types.MappingProxyType[str, str] = types.MappingProxyType( + { + # merged gives 'geosparql', rdflib/W3C uses 'geo' + "http://www.opengis.net/ont/geosparql#": "geo", + # merged gives 'sc', rdflib/W3C uses 'schema' + "https://schema.org/": "schema", + # merged gives 'WGS84', rdflib uses 'wgs' + "https://www.w3.org/2003/01/geo/wgs84_pos#": "wgs", + "http://www.w3.org/2003/01/geo/wgs84_pos#": "wgs", + } +) + + +def normalize_graph_prefixes(graph: "Graph", schema_prefixes: dict[str, str]) -> None: + """Normalise non-standard prefix aliases in an rdflib Graph. + + For each prefix bound in *schema_prefixes* (mapping prefix name → + namespace URI), check whether ``well_known_prefix_map()`` knows a + standard name for that URI. If the standard name differs from the + schema-declared name, rebind the namespace to the standard name. + + This is the **shared implementation** used by OWL, SHACL, and (via a + different code-path) JSON-LD context generators so that all serialisation + formats agree on prefix names when ``--normalize-prefixes`` is active. + + :param graph: rdflib Graph whose namespace bindings should be adjusted. + :param schema_prefixes: mapping of prefix name → namespace URI string, + typically from ``schema.prefixes``. + """ + from rdflib import Namespace + + wk = well_known_prefix_map() + + # Phase 1: normalise schema-declared prefixes. + for old_pfx, ns_uri in schema_prefixes.items(): + ns_str = str(ns_uri) + std_pfx = wk.get(ns_str) + if not std_pfx or std_pfx == old_pfx: + continue + # Collision: the user explicitly declared std_pfx for a different + # namespace — do not clobber their binding. + if std_pfx in schema_prefixes and schema_prefixes[std_pfx] != ns_str: + logger.warning( + "Prefix collision: cannot rename '%s' to '%s' because '%s' is already " + "declared for <%s>; skipping normalisation for <%s>", + old_pfx, + std_pfx, + std_pfx, + schema_prefixes[std_pfx], + ns_str, + ) + continue + # Rebind: remove old prefix, add standard prefix. + # ``replace=True`` forces the new prefix even if the prefix name + # is already bound to a different namespace. + graph.bind(std_pfx, Namespace(ns_str), override=True, replace=True) + + # Phase 2: normalise runtime-injected bindings (e.g. metamodel defaults). + # The linkml-runtime / rdflib may inject well-known namespaces under + # non-standard prefix names. After Phase 1 rebinds schema-declared + # prefixes, orphaned runtime bindings can appear as ``schema1``, ``dc0``, + # etc. Scan the graph's current bindings and fix any that map to a + # well-known namespace under a non-standard name, provided the standard + # name isn't already claimed by the user for a different namespace. + # + # Guard: if Phase 1 already bound std_pfx to a different URI (e.g. + # ``schema`` → ``https://schema.org/``), do not clobber it with the + # HTTP variant (``http://schema.org/``). Build a snapshot of the + # current bindings after Phase 1 to detect this. + current_bindings = {str(p): str(n) for p, n in graph.namespaces()} + for pfx, ns in list(graph.namespaces()): + pfx_str, ns_str = str(pfx), str(ns) + std_pfx = wk.get(ns_str) + if not std_pfx or std_pfx == pfx_str: + continue + # Same collision check as Phase 1: respect user-declared prefixes. + if std_pfx in schema_prefixes and schema_prefixes[std_pfx] != ns_str: + continue + # Guard: if std_pfx is already bound to a different (correct) URI + # by Phase 1, do not overwrite it. This prevents the HTTP variant + # of schema.org from clobbering the HTTPS binding. + if std_pfx in current_bindings and current_bindings[std_pfx] != ns_str: + continue + graph.bind(std_pfx, Namespace(ns_str), override=True, replace=True) + + @dataclass class Generator(metaclass=abc.ABCMeta): """ @@ -249,6 +401,12 @@ def namespaces(self, value: Namespaces | None) -> None: stacktrace: bool = False """True means print stack trace, false just error message""" + normalize_prefixes: bool = False + """True means normalise non-standard prefix aliases to well-known names + from the ``prefixmaps`` package (linked_data + merged contexts, with + overrides for rdflib/W3C canonical forms). E.g. ``sdo`` → ``schema`` + for ``https://schema.org/``.""" + include: str | Path | SchemaDefinition | None = None """If set, include extra schema outside of the imports mechanism""" @@ -1102,6 +1260,16 @@ def decorator(f: Command) -> Command: callback=stacktrace_callback, ) ) + f.params.append( + Option( + ("--normalize-prefixes/--no-normalize-prefixes",), + default=False, + show_default=True, + help="Normalise non-standard prefix aliases to rdflib's curated default names " + "(e.g. sdo → schema for https://schema.org/). " + "Supported by OWL, SHACL, and JSON-LD Context generators.", + ) + ) return f diff --git a/tests/linkml/test_generators/test_jsonldcontextgen.py b/tests/linkml/test_generators/test_jsonldcontextgen.py index 72089eba44..3e0ad405f6 100644 --- a/tests/linkml/test_generators/test_jsonldcontextgen.py +++ b/tests/linkml/test_generators/test_jsonldcontextgen.py @@ -1790,3 +1790,118 @@ def test_identifier_context_round_trips_to_rdf(tmp_path, use_curies): ex = "https://example.org/identifier-test/" assert set(graph) == {(URIRef(ex + "thing1"), URIRef(ex + "name"), Literal("n"))} + + +def test_normalize_prefixes_renames_nonstandard_alias(tmp_path): + """When --normalize-prefixes is set, non-standard aliases are replaced by rdflib defaults. + + rdflib binds ``dc`` to ``http://purl.org/dc/elements/1.1/`` by default. + A schema that declares ``dce`` for the same URI should have it normalised + to ``dc`` when the flag is enabled. + + See: rdflib default namespace bindings. + """ + schema = tmp_path / "schema.yaml" + schema.write_text( + """\ +id: https://example.org/test +name: test_normalize +default_prefix: ex +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + dce: http://purl.org/dc/elements/1.1/ +imports: + - linkml:types +classes: + Record: + class_uri: ex:Record + attributes: + title: + range: string + slot_uri: dce:title +""", + encoding="utf-8", + ) + + # Flag OFF (default): non-standard alias preserved + ctx_off = json.loads(ContextGenerator(str(schema), normalize_prefixes=False).serialize())["@context"] + assert "dce" in ctx_off, "With flag off, original prefix 'dce' must be preserved" + + # Flag ON: rdflib default name used + ctx_on = json.loads(ContextGenerator(str(schema), normalize_prefixes=True).serialize())["@context"] + assert "dc" in ctx_on, "With flag on, 'dce' should be normalised to 'dc'" + assert "dce" not in ctx_on, "With flag on, original alias 'dce' should be removed" + assert ctx_on["dc"] == "http://purl.org/dc/elements/1.1/" + + +def test_normalize_prefixes_default_is_off(tmp_path): + """The --normalize-prefixes flag defaults to False — no prefix renaming. + + Ensures backward compatibility: existing schemas produce identical output. + """ + schema = tmp_path / "schema.yaml" + schema.write_text( + """\ +id: https://example.org/test +name: test_default +default_prefix: ex +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + sdo: https://schema.org/ +imports: + - linkml:types +classes: + Thing: + class_uri: sdo:Thing + attributes: + name: + range: string + slot_uri: sdo:name +""", + encoding="utf-8", + ) + + ctx = json.loads(ContextGenerator(str(schema)).serialize())["@context"] + # Without the flag, the schema's own prefix name must be preserved + assert "sdo" in ctx, "Default behavior must preserve schema-declared prefix 'sdo'" + + +def test_normalize_prefixes_curie_remapping(tmp_path): + """CURIEs in element @id values use the normalised prefix name. + + When ``sdo`` is normalised to ``schema``, slot URIs like ``sdo:name`` + must appear as ``schema:name`` in the generated context. + """ + schema = tmp_path / "schema.yaml" + schema.write_text( + """\ +id: https://example.org/test +name: test_curie +default_prefix: ex +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + sdo: https://schema.org/ +imports: + - linkml:types +classes: + Person: + class_uri: sdo:Person + attributes: + full_name: + range: string + slot_uri: sdo:name +""", + encoding="utf-8", + ) + + ctx = json.loads(ContextGenerator(str(schema), normalize_prefixes=True).serialize())["@context"] + # The prefix declaration must use the standard name + assert "schema" in ctx, "Normalised prefix 'schema' must appear" + # Element @id must use the normalised prefix + person = ctx.get("Person", {}) + assert person.get("@id", "").startswith("schema:"), ( + f"Person @id should use normalised prefix 'schema:', got {person}" + ) diff --git a/tests/linkml/test_generators/test_normalize_prefixes.py b/tests/linkml/test_generators/test_normalize_prefixes.py new file mode 100644 index 0000000000..0a832a5791 --- /dev/null +++ b/tests/linkml/test_generators/test_normalize_prefixes.py @@ -0,0 +1,545 @@ +"""Tests for the --normalize-prefixes flag across all generators. + +Verifies that non-standard prefix aliases (e.g. ``sdo`` for ``https://schema.org/``) +are normalised to well-known names (e.g. ``schema``) consistently in OWL, SHACL, +and JSON-LD context output. + +References: +- prefix.cc — community consensus RDF prefix registry +- rdflib 7.x curated default namespace bindings +- W3C Turtle §2.4 — prefix declarations are syntactic sugar +""" + +import json +import logging +import re +import textwrap + +import pytest + +# ── Shared test schema ────────────────────────────────────────────── + +SCHEMA_SDO = textwrap.dedent("""\ + id: https://example.org/test + name: test_normalize + default_prefix: ex + prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + sdo: https://schema.org/ + imports: + - linkml:types + classes: + Person: + class_uri: sdo:Person + attributes: + full_name: + range: string + slot_uri: sdo:name +""") + +SCHEMA_DCE = textwrap.dedent("""\ + id: https://example.org/test + name: test_normalize_dce + default_prefix: ex + prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + dce: http://purl.org/dc/elements/1.1/ + imports: + - linkml:types + classes: + Record: + class_uri: ex:Record + attributes: + title: + range: string + slot_uri: dce:title +""") + +# HTTP variant — linkml-runtime historically binds schema: http://schema.org/ +# while rdflib (and the W3C) prefer https://schema.org/. The normalize flag +# must handle both. +SCHEMA_HTTP_SDO = textwrap.dedent("""\ + id: https://example.org/test + name: test_http_schema + default_prefix: ex + prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + sdo: http://schema.org/ + imports: + - linkml:types + classes: + Place: + class_uri: sdo:Place + attributes: + geo: + range: string + slot_uri: sdo:geo +""") + +# Collision scenario: user declares 'foaf' for a custom namespace AND 'myfoaf' +# for http://xmlns.com/foaf/0.1/. Normalisation must NOT clobber the user's 'foaf'. +# Uses 'foaf' instead of 'schema' because 'schema' is declared in linkml:types, +# which causes a SchemaLoader merge conflict before normalisation even runs. +SCHEMA_COLLISION = textwrap.dedent("""\ + id: https://example.org/test + name: test_collision + default_prefix: ex + prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + foaf: https://something-else.org/ + myfoaf: http://xmlns.com/foaf/0.1/ + imports: + - linkml:types + classes: + Agent: + class_uri: myfoaf:Agent + attributes: + label: + range: string + slot_uri: myfoaf:name +""") + + +def _write_schema(tmp_path, content: str, name: str = "schema.yaml") -> str: + """Write schema content to a temporary file and return its path as string.""" + p = tmp_path / name + p.write_text(content, encoding="utf-8") + return str(p) + + +def _turtle_prefixes(ttl: str) -> dict[str, str]: + """Extract @prefix declarations from Turtle output → {prefix: namespace}.""" + result = {} + for m in re.finditer(r"@prefix\s+(\w+):\s+<([^>]+)>", ttl): + result[m.group(1)] = m.group(2) + return result + + +# ── OWL Generator Tests ───────────────────────────────────────────── + + +def test_owl_sdo_normalised_to_schema(tmp_path): + """sdo → schema when --normalize-prefixes is active.""" + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = OwlSchemaGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + assert "schema" in pfx, f"Expected 'schema' prefix in OWL output, got: {sorted(pfx)}" + assert pfx["schema"] == "https://schema.org/" + assert "sdo" not in pfx, "Non-standard 'sdo' prefix should be removed" + + +def test_owl_flag_off_preserves_original(tmp_path): + """Without the flag, schema-declared prefix names are preserved.""" + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = OwlSchemaGenerator(schema_path, normalize_prefixes=False).serialize() + pfx = _turtle_prefixes(ttl) + assert "sdo" in pfx, "With flag off, original prefix 'sdo' must be preserved" + + +def test_owl_dce_normalised_to_dc(tmp_path): + """dce → dc for http://purl.org/dc/elements/1.1/ in graph bindings. + + Note: rdflib's Turtle serializer only emits @prefix declarations for + namespaces actually used in triples. Since the OWL generator may not + produce triples using dc:elements URIs for simple attribute schemas, + we verify the graph's namespace bindings directly. + """ + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_DCE) + gen = OwlSchemaGenerator(schema_path, normalize_prefixes=True) + graph = gen.as_graph() + bound = {str(p): str(n) for p, n in graph.namespaces()} + assert "dc" in bound, f"Expected 'dc' in graph bindings, got: {sorted(bound)}" + assert bound["dc"] == "http://purl.org/dc/elements/1.1/" + + +def test_owl_custom_prefix_not_affected(tmp_path): + """Domain-specific prefixes (e.g. 'ex') are not touched by normalisation.""" + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = OwlSchemaGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + assert "ex" in pfx, "Custom prefix 'ex' must survive normalisation" + assert pfx["ex"] == "https://example.org/" + + +def test_owl_http_schema_org_normalised(tmp_path): + """http://schema.org/ (HTTP variant) also normalises to 'schema'. + + The linkml-runtime historically binds ``schema: http://schema.org/`` + while the W3C and rdflib prefer ``https://schema.org/``. Both + variants must be recognised by the static well-known prefix map. + """ + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_HTTP_SDO) + ttl = OwlSchemaGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + assert "schema" in pfx, f"Expected 'schema' prefix for http://schema.org/, got: {sorted(pfx)}" + assert "sdo" not in pfx + + +def test_owl_no_schema1_from_runtime_http_binding(tmp_path): + """Runtime-injected ``schema: http://schema.org/`` must not create ``schema1``. + + The linkml metamodel (types.yaml) declares ``schema: http://schema.org/`` + (HTTP). When a user schema declares ``sdo: https://schema.org/`` (HTTPS), + normalisation must clean up *both* variants so the output never contains + auto-generated suffixed prefixes like ``schema1``. + """ + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = OwlSchemaGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + suffixed = [p for p in pfx if re.match(r"schema\d+", p)] + assert not suffixed, ( + f"Auto-generated suffixed prefix(es) {suffixed} found — runtime http://schema.org/ binding was not cleaned up" + ) + + +# ── SHACL Generator Tests ─────────────────────────────────────────── + + +def test_shacl_sdo_normalised_to_schema(tmp_path): + """sdo → schema when --normalize-prefixes is active.""" + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = ShaclGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + assert "schema" in pfx, f"Expected 'schema' prefix in SHACL output, got: {sorted(pfx)}" + assert pfx["schema"] == "https://schema.org/" + assert "sdo" not in pfx, "Non-standard 'sdo' prefix should be removed" + + +def test_shacl_flag_off_preserves_original(tmp_path): + """Without the flag, schema-declared prefix names are preserved.""" + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = ShaclGenerator(schema_path, normalize_prefixes=False).serialize() + pfx = _turtle_prefixes(ttl) + assert "sdo" in pfx, "With flag off, original prefix 'sdo' must be preserved" + + +def test_shacl_dce_normalised_to_dc(tmp_path): + """dce → dc for http://purl.org/dc/elements/1.1/.""" + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_DCE) + ttl = ShaclGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + assert "dc" in pfx, f"Expected 'dc' prefix in SHACL output, got: {sorted(pfx)}" + assert pfx["dc"] == "http://purl.org/dc/elements/1.1/" + assert "dce" not in pfx, "Non-standard 'dce' prefix should be removed" + + +def test_shacl_custom_prefix_not_affected(tmp_path): + """Domain-specific prefixes (e.g. 'ex') are not touched by normalisation. + + Note: rdflib only emits @prefix for namespaces used in triples. + We verify graph bindings directly. + """ + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + gen = ShaclGenerator(schema_path, normalize_prefixes=True) + graph = gen.as_graph() + bound = {str(p): str(n) for p, n in graph.namespaces()} + assert "ex" in bound, f"Custom prefix 'ex' must survive in graph bindings, got: {sorted(bound)}" + assert bound["ex"] == "https://example.org/" + + +def test_shacl_http_schema_org_normalised(tmp_path): + """http://schema.org/ (HTTP variant) also normalises to 'schema'.""" + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_HTTP_SDO) + ttl = ShaclGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + assert "schema" in pfx, f"Expected 'schema' prefix for http://schema.org/, got: {sorted(pfx)}" + assert "sdo" not in pfx + + +def test_shacl_no_schema1_from_runtime_http_binding(tmp_path): + """Runtime-injected ``schema: http://schema.org/`` must not create ``schema1``. + + Same scenario as the OWL test: linkml:types imports bring in + ``schema: http://schema.org/`` while the user schema has + ``sdo: https://schema.org/``. Phase 2 of normalisation must + clean up the orphaned HTTP binding. + """ + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + ttl = ShaclGenerator(schema_path, normalize_prefixes=True).serialize() + pfx = _turtle_prefixes(ttl) + suffixed = [p for p in pfx if re.match(r"schema\d+", p)] + assert not suffixed, ( + f"Auto-generated suffixed prefix(es) {suffixed} found — runtime http://schema.org/ binding was not cleaned up" + ) + + +# ── JSON-LD Context Generator Tests ───────────────────────────────── + + +def test_context_http_schema_org_normalised(tmp_path): + """http://schema.org/ (HTTP variant) normalises to 'schema' in JSON-LD context. + + This covers the edge case where linkml-runtime's ``schema: http://schema.org/`` + conflicts with rdflib's ``schema: https://schema.org/``. The stale binding + must be removed and replaced with the correct one. + """ + from linkml.generators.jsonldcontextgen import ContextGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_HTTP_SDO) + ctx = json.loads(ContextGenerator(schema_path, normalize_prefixes=True).serialize())["@context"] + assert "schema" in ctx, "HTTP schema.org should normalise to 'schema'" + assert "sdo" not in ctx, "Non-standard 'sdo' should be removed" + # The namespace URI must match the schema-declared one (http, not https) + schema_val = ctx["schema"] + if isinstance(schema_val, dict): + schema_val = schema_val.get("@id", "") + assert schema_val == "http://schema.org/", f"Namespace URI must be preserved: got {schema_val}" + + +# ── Static Prefix Map Tests ───────────────────────────────────────── + + +def test_well_known_prefix_map_returns_dict(): + from linkml.utils.generator import well_known_prefix_map + + wk = well_known_prefix_map() + assert isinstance(wk, dict) + assert len(wk) >= 29, f"Expected ≥29 entries, got {len(wk)}" + + +def test_well_known_prefix_map_schema_https(): + from linkml.utils.generator import well_known_prefix_map + + wk = well_known_prefix_map() + assert wk["https://schema.org/"] == "schema" + + +def test_well_known_prefix_map_schema_http_variant(): + """Both http and https schema.org must map to 'schema'.""" + from linkml.utils.generator import well_known_prefix_map + + wk = well_known_prefix_map() + assert wk["http://schema.org/"] == "schema" + + +def test_well_known_prefix_map_dc_elements(): + from linkml.utils.generator import well_known_prefix_map + + wk = well_known_prefix_map() + assert wk["http://purl.org/dc/elements/1.1/"] == "dc" + + +def test_well_known_prefix_map_returns_copy(): + """Callers should not be able to mutate the internal map.""" + from linkml.utils.generator import well_known_prefix_map + + wk1 = well_known_prefix_map() + wk1["http://never-in-any-real-prefix-map.test/"] = "test" + wk2 = well_known_prefix_map() + assert "http://never-in-any-real-prefix-map.test/" not in wk2 + + +def test_well_known_prefix_map_fully_resolved_from_prefixmaps(): + """All rdflib defaults must be resolved from prefixmaps (no residual map). + + This is the proof that pinning prefixmaps to the commit containing + linkml/prefixmaps#81 resolves all well-known prefixes without any + hardcoded fallback. If this test fails after a prefixmaps update, + add the missing prefix to the upstream linked_data.curated.yaml. + """ + from rdflib import Graph as RdfGraph + + from linkml.utils.generator import well_known_prefix_map + + wk = well_known_prefix_map() + rdflib_map = {str(ns): str(pfx) for pfx, ns in RdfGraph().namespaces() if str(pfx)} + missing = {ns: pfx for ns, pfx in rdflib_map.items() if ns not in wk} + assert not missing, f"Prefix map missing rdflib defaults (add to prefixmaps upstream): {missing}" + + +# ── Cross-Generator Consistency Tests ──────────────────────────────── + + +def test_all_generators_normalise_sdo_to_schema(tmp_path): + """OWL, SHACL, and JSON-LD context must all use 'schema' for schema.org.""" + from linkml.generators.jsonldcontextgen import ContextGenerator + from linkml.generators.owlgen import OwlSchemaGenerator + from linkml.generators.shaclgen import ShaclGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + + owl_ttl = OwlSchemaGenerator(schema_path, normalize_prefixes=True).serialize() + shacl_ttl = ShaclGenerator(schema_path, normalize_prefixes=True).serialize() + ctx = json.loads(ContextGenerator(schema_path, normalize_prefixes=True).serialize())["@context"] + + owl_pfx = _turtle_prefixes(owl_ttl) + shacl_pfx = _turtle_prefixes(shacl_ttl) + + assert "schema" in owl_pfx, "OWL must use 'schema'" + assert "schema" in shacl_pfx, "SHACL must use 'schema'" + assert "schema" in ctx, "JSON-LD context must use 'schema'" + + assert "sdo" not in owl_pfx, "OWL must not have 'sdo'" + assert "sdo" not in shacl_pfx, "SHACL must not have 'sdo'" + assert "sdo" not in ctx, "JSON-LD context must not have 'sdo'" + + +# ── Prefix Collision Tests ──────────────────────────────────────────── + + +@pytest.mark.parametrize( + "generator_cls,generator_module", + [ + ("OwlSchemaGenerator", "linkml.generators.owlgen"), + ("ShaclGenerator", "linkml.generators.shaclgen"), + ], + ids=["owl", "shacl"], +) +def test_graph_generator_collision_skips_rename(tmp_path, caplog, generator_cls, generator_module): + """Graph generators: myfoaf must NOT be renamed to 'foaf' when user claims that name.""" + import importlib + + mod = importlib.import_module(generator_module) + cls = getattr(mod, generator_cls) + + schema_path = _write_schema(tmp_path, SCHEMA_COLLISION) + with caplog.at_level(logging.WARNING): + gen = cls(schema_path, normalize_prefixes=True) + graph = gen.as_graph() + bound = {str(p): str(n) for p, n in graph.namespaces()} + assert "myfoaf" in bound, "Non-standard 'myfoaf' must remain when collision prevents renaming" + assert bound["myfoaf"] == "http://xmlns.com/foaf/0.1/" + assert "collision" in caplog.text.lower(), f"Expected collision warning, got: {caplog.text}" + + +def test_context_collision_preserves_user_prefix(tmp_path, caplog): + """JSON-LD: user's 'foaf: https://something-else.org/' must survive.""" + from linkml.generators.jsonldcontextgen import ContextGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_COLLISION) + with caplog.at_level(logging.WARNING): + ctx = json.loads(ContextGenerator(schema_path, normalize_prefixes=True).serialize())["@context"] + # User's 'foaf' binding preserved + foaf_val = ctx.get("foaf") + if isinstance(foaf_val, dict): + foaf_val = foaf_val.get("@id", "") + assert foaf_val == "https://something-else.org/", f"User's 'foaf' binding must be preserved, got: {foaf_val}" + # myfoaf must remain (not renamed to foaf) + assert "myfoaf" in ctx, "Non-standard 'myfoaf' must remain when collision prevents renaming" + # Warning emitted + assert "collision" in caplog.text.lower(), f"Expected collision warning, got: {caplog.text}" + + +# ── JSONLDGenerator Flag Forwarding Tests ───────────────────────────── + + +def test_jsonld_generator_forwards_normalize_prefixes(tmp_path): + """JSONLDGenerator must pass normalize_prefixes to embedded ContextGenerator. + + Without forwarding, the inline @context in JSON-LD output would keep + non-standard prefix aliases even when --normalize-prefixes is set. + """ + from linkml.generators.jsonldgen import JSONLDGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + out = JSONLDGenerator(schema_path, normalize_prefixes=True).serialize() + parsed = json.loads(out) + # The @context may be a list; find the dict entry + ctx = parsed.get("@context", {}) + if isinstance(ctx, list): + for item in ctx: + if isinstance(item, dict): + ctx = item + break + assert "sdo" not in ctx, "normalize_prefixes not forwarded: 'sdo' still in embedded @context" + + +# ── Phase 2 HTTP/HTTPS Overwrite Bug Tests ──────────────────────────── + + +def test_phase2_does_not_overwrite_https_with_http(tmp_path): + """When Phase 1 binds schema → https://schema.org/, Phase 2 must not + overwrite it with http://schema.org/ from the runtime metamodel. + + Reproduction: linkml:types imports bring schema: http://schema.org/ + (HTTP) while the user schema has sdo: https://schema.org/ (HTTPS). + Phase 1 normalises sdo → schema (HTTPS). Phase 2 must not then + rebind schema → http://schema.org/ when it encounters the runtime + HTTP binding. + """ + from linkml.generators.owlgen import OwlSchemaGenerator + + schema_path = _write_schema(tmp_path, SCHEMA_SDO) + gen = OwlSchemaGenerator(schema_path, normalize_prefixes=True) + graph = gen.as_graph() + bound = {str(p): str(n) for p, n in graph.namespaces()} + assert "schema" in bound, f"Expected 'schema' in bindings, got: {sorted(bound)}" + # MUST be HTTPS (from the user's schema), not HTTP (from runtime) + assert bound["schema"] == "https://schema.org/", ( + f"Phase 2 overwrote HTTPS with HTTP: schema bound to {bound['schema']}" + ) + + +def test_normalize_graph_prefixes_phase2_guard(): + """Direct unit test for the Phase 2 guard in normalize_graph_prefixes. + + Simulates the exact scenario: Phase 1 binds schema → https://schema.org/, + then Phase 2 encounters schema1 → http://schema.org/ and must NOT rebind. + """ + from rdflib import Graph, Namespace, URIRef + + from linkml.utils.generator import normalize_graph_prefixes + + g = Graph(bind_namespaces="none") + # Simulate Phase 1 result + g.bind("schema", Namespace("https://schema.org/")) + # Simulate runtime-injected HTTP variant (would appear as schema1) + g.bind("schema1", Namespace("http://schema.org/")) + # Add a triple so the graph isn't empty + g.add((URIRef("https://example.org/s"), URIRef("https://schema.org/name"), URIRef("https://example.org/o"))) + + normalize_graph_prefixes(g, {"sdo": "https://schema.org/"}) + + bound = {str(p): str(n) for p, n in g.namespaces()} + assert bound.get("schema") == "https://schema.org/", f"Phase 2 guard failed: schema bound to {bound.get('schema')}" + + +def test_empty_schema_no_crash(tmp_path): + """A schema with no custom prefixes must not crash normalize_graph_prefixes.""" + from linkml.generators.owlgen import OwlSchemaGenerator + + (tmp_path / "empty.yaml").write_text( + textwrap.dedent("""\ + id: https://example.org/empty + name: empty + default_prefix: ex + prefixes: + linkml: https://w3id.org/linkml/ + ex: https://example.org/ + imports: + - linkml:types + """), + encoding="utf-8", + ) + # Should not raise + gen = OwlSchemaGenerator(str(tmp_path / "empty.yaml"), normalize_prefixes=True) + ttl = gen.serialize() + assert len(ttl) > 0 diff --git a/uv.lock b/uv.lock index f45070d4ed..397f49e12a 100644 --- a/uv.lock +++ b/uv.lock @@ -2526,7 +2526,7 @@ requires-dist = [ { name = "parse" }, { name = "platformdirs", specifier = ">=4.12.1" }, { name = "prefixcommons", specifier = ">=0.1.7" }, - { name = "prefixmaps", specifier = ">=0.2.2" }, + { name = "prefixmaps", git = "https://github.com/linkml/prefixmaps?rev=75435150a1b31760b9780af2b64a265943a9b263" }, { name = "pydantic", specifier = ">=2.13.5,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.15.0" }, { name = "pyjsg", specifier = ">=0.12.3" }, @@ -3785,16 +3785,12 @@ wheels = [ [[package]] name = "prefixmaps" -version = "0.2.6" -source = { registry = "https://pypi.org/simple" } +version = "0.2.7.post2.dev0+7543515" +source = { git = "https://github.com/linkml/prefixmaps?rev=75435150a1b31760b9780af2b64a265943a9b263#75435150a1b31760b9780af2b64a265943a9b263" } 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" From e47c173a3244be0781a5a8c9f54c18a14193272f Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 2 Oct 2026 16:41:12 +0200 Subject: [PATCH 24/30] feat(gen-owl): honor instantiates on emitted schema resources Translate explicit instantiates declarations to rdf:type on the emitted schema resource through one shared helper. Apply the mapping consistently to schemas, classes, slots, types, enums, and non-literal permissible values. Literal permissible values cannot be RDF subjects and produce a warning. Membership types the schema element itself, not its ordinary data instances, and does not become subclass inheritance. Existing enum representation and OWL class/individual punning remain intact. No additional flag is needed because the schema already declares the relationship. Cover CURIEs, absolute and minted IRIs, literal values, representation modes, and the distinction between gen-rdf metamodel serialization and gen-owl ontology assertions. Document the LinkML and OWL specification references. Signed-off-by: jdsika --- docs/generators/owl.rst | 46 +++++++ .../linkml/src/linkml/generators/owlgen.py | 21 ++- tests/linkml/test_generators/test_owlgen.py | 122 ++++++++++++++++++ 3 files changed, 187 insertions(+), 2 deletions(-) diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst index a87f7a6c70..fb65ebdb4d 100644 --- a/docs/generators/owl.rst +++ b/docs/generators/owl.rst @@ -155,6 +155,52 @@ You can control enum and permissible value representation directly in your schem implements: - rdfs:Literal +**Metaclass membership with ``instantiates``** - This LinkML field types a schema +element itself; it does not add slots or type that element's data instances. The OWL +generator maps each declared membership to ``rdf:type`` on the emitted resource. +The mapping applies consistently to schemas, classes, slots, locally emitted types, +enums, and non-literal permissible values. No additional option is required: the +schema already declares the relationship. + +For example, a permissible value represented as a named individual can declare +membership in an external vocabulary class: + +.. code-block:: yaml + + enums: + LinkCategory: + implements: + - owl:NamedIndividual + permissible_values: + isLicense: + meaning: ex:isLicense + instantiates: + - vocab:LicenseCategory + +.. code-block:: turtle + + ex:isLicense a owl:NamedIndividual, ex:LinkCategory, vocab:LicenseCategory . + +For an enum of named individuals, its own class still uses ``owl:oneOf``; this does +not close the external class. A permissible value rendered as an OWL class retains +that representation and also participates as an individual in the membership +assertion (OWL punning). Membership does not become ``rdfs:subClassOf`` and is not +inherited by instances of the emitted class. ``instantiates`` is ignored, with a +warning, on a permissible value rendered as a literal, because RDF literals cannot +be subjects of ``rdf:type`` triples. + +``gen-rdf`` and ``gen-jsonld`` serialize the schema as metamodel data and retain +``linkml:instantiates``. ``gen-owl`` translates that declaration into ontology +membership. Instance validators such as JSON Schema and SHACL must not apply the +schema element's metaclass to ordinary data instances. + +See the `LinkML instantiation guide +`_, +the `instantiates metamodel definition +`_, and the OWL 2 +specifications for `metamodeling `_ +and `mapping class assertions to RDF `_. + **Using URIs vs. text for permissible values:** .. code-block:: yaml diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index ec1a11baab..36db84b3ca 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -32,6 +32,7 @@ ClassDefinitionName, ClassRule, Definition, + Element, EnumDefinition, EnumDefinitionName, PermissibleValue, @@ -362,9 +363,9 @@ def serialize(self, **kwargs: Any) -> str: fmt = "turtle" if self.format in ["owl", "ttl"] else self.format return canonicalize_rdf_graph(self.graph, output_format=fmt, diff_stable=self.diff_stable) - def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None: + def add_metadata(self, e: Element | PermissibleValue, uri: URIRef) -> None: """ - Add annotation properties. + Add annotation properties and explicit metaclass membership. Set the profile attribute to the appropriate OWL profile. Human-readable string literals are language-tagged when @@ -380,6 +381,8 @@ def add_metadata(self, e: Definition | PermissibleValue, uri: URIRef) -> None: sn_mappings = msv.slot_name_mappings() lang = self._resolve_language(e) + self._add_instantiates(e, uri) + # iterate through all the assigned metamodel slots for metaslot_name, metaslot_value in vars(e).items(): if not metaslot_value: @@ -1087,11 +1090,23 @@ def add_slot(self, slot: SlotDefinition, attribute: bool = False) -> None: for mixin in slot.mixins: self.graph.add((slot_uri, RDFS.subPropertyOf, self._prop_uri(mixin))) + def _add_instantiates(self, element: Element | PermissibleValue, uri: URIRef) -> None: + """Type the emitted schema resource itself, without typing its data instances. + + ``instantiates`` declares metaclass membership in LinkML. In OWL's RDF + mapping, class assertions are ``rdf:type`` triples; a resource also used + as an OWL class or property is interpreted separately as an individual. + """ + for instantiated in element.instantiates: + self.graph.add((uri, RDF.type, URIRef(self.schemaview.expand_curie(instantiated)))) + def add_type(self, typ: TypeDefinition) -> None: type_uri = self._type_uri(typ.name) if typ.from_schema == "https://w3id.org/linkml/types": return + self._add_instantiates(typ, type_uri) + if self.metaclasses: self.graph.add( ( @@ -1203,6 +1218,8 @@ def add_enum(self, e: EnumDefinition) -> None: pv_node = Literal(pv.text) if pv.meaning: logger.warning(f"Meaning on literal {pv.text} in {e.name} is ignored") + if pv.instantiates: + logger.warning(f"Instantiates on literal {pv.text} in {e.name} is ignored") else: pv_node = self._permissible_value_uri(pv, enum_uri, e) pv_uris.append(pv_node) diff --git a/tests/linkml/test_generators/test_owlgen.py b/tests/linkml/test_generators/test_owlgen.py index bb39294f27..0b88e97ad5 100644 --- a/tests/linkml/test_generators/test_owlgen.py +++ b/tests/linkml/test_generators/test_owlgen.py @@ -9,6 +9,7 @@ from linkml import METAMODEL_CONTEXT_URI from linkml.generators.owlgen import MetadataProfile, OwlSchemaGenerator +from linkml.generators.rdfgen import RDFGenerator from linkml_runtime.linkml_model import SlotDefinition from linkml_runtime.linkml_model.meta import ( AnonymousClassExpression, @@ -1232,3 +1233,124 @@ def test_complement_of_union_of_mixed_none_filters_silently(): # Should succeed and return a BNode (the complement expression). assert result is not None assert isinstance(result, BNode) + + +_INSTANTIATES_SCHEMA = """ +id: http://example.org/test-schema +name: instantiates_test +prefixes: + linkml: https://w3id.org/linkml/ + ex: http://example.org/test-schema/ + vocab: http://example.org/vocab/ +default_prefix: ex +imports: + - linkml:types +enums: + LinkCategory: + implements: + - owl:NamedIndividual + permissible_values: + isLicense: + meaning: ex:isLicense + instantiates: + - vocab:LicenseCategory + isManifest: + meaning: ex:isManifest + instantiates: + - vocab:ManifestCategory + - vocab:Category + isMedia: + meaning: ex:isMedia +""" + +VOCAB = Namespace("http://example.org/vocab/") + + +def test_permissible_value_instantiates_types_the_value() -> None: + """Each ``instantiates`` value of a permissible value becomes an rdf:type of its IRI.""" + g = Graph() + g.parse(data=OwlSchemaGenerator(_INSTANTIATES_SCHEMA, metaclasses=False).serialize(), format="turtle") + + assert (EX.isLicense, RDF.type, VOCAB.LicenseCategory) in g + assert (EX.isManifest, RDF.type, VOCAB.ManifestCategory) in g + assert (EX.isManifest, RDF.type, VOCAB.Category) in g + # the value stays an individual of the enum, which still closes its own class + for pv in (EX.isLicense, EX.isManifest, EX.isMedia): + assert (pv, RDF.type, OWL.NamedIndividual) in g + assert (pv, RDF.type, EX.LinkCategory) in g + one_of = g.value(EX.LinkCategory, OWL.oneOf) + assert set(Collection(g, one_of)) == {EX.isLicense, EX.isManifest, EX.isMedia} + # a value without instantiates gets no further type, and the instantiated classes stay open + assert set(g.objects(EX.isMedia, RDF.type)) == {OWL.NamedIndividual, EX.LinkCategory} + assert g.value(VOCAB.LicenseCategory, OWL.oneOf) is None + + +def test_permissible_value_instantiates_ignored_on_literal(caplog: pytest.LogCaptureFixture) -> None: + """A permissible value rendered as a literal cannot be typed, so instantiates is ignored with a warning.""" + schema = _INSTANTIATES_SCHEMA.replace("owl:NamedIndividual", "rdfs:Literal") + with caplog.at_level(logging.WARNING): + g = Graph() + g.parse(data=OwlSchemaGenerator(schema, metaclasses=False).serialize(), format="turtle") + assert not list(g.triples((None, RDF.type, VOCAB.LicenseCategory))) + assert any("Instantiates on literal isLicense" in rec.message for rec in caplog.records) + + +@pytest.mark.parametrize("metaclasses", [False, True]) +@pytest.mark.parametrize("type_objects", [False, True]) +def test_instantiates_applies_to_emitted_schema_elements(metaclasses: bool, type_objects: bool) -> None: + """Metaclass membership is explicit schema metadata, independent of generated metamodel types.""" + schema = _INSTANTIATES_SCHEMA.replace( + "name: instantiates_test", "name: instantiates_test\ninstantiates: [vocab:SchemaProfile]" + ).replace(" LinkCategory:\n", " LinkCategory:\n instantiates: [vocab:EnumProfile]\n") + schema += """ +classes: + Entry: + instantiates: [vocab:ClassProfile] + slots: [code] +slots: + code: + range: Code + instantiates: [vocab:SlotProfile] +types: + Code: + typeof: string + uri: ex:Code + instantiates: [vocab:TypeProfile] +""" + g = OwlSchemaGenerator(schema, metaclasses=metaclasses, type_objects=type_objects).as_graph() + for resource, metaclass in ( + (URIRef("http://example.org/test-schema"), VOCAB.SchemaProfile), + (EX.Entry, VOCAB.ClassProfile), + (EX.code, VOCAB.SlotProfile), + (EX.Code, VOCAB.TypeProfile), + (EX.LinkCategory, VOCAB.EnumProfile), + (EX.isLicense, VOCAB.LicenseCategory), + ): + assert (resource, RDF.type, metaclass) in g + assert (resource, RDFS.subClassOf, metaclass) not in g + assert (EX.isLicense, RDF.type, VOCAB.EnumProfile) not in g + + +@pytest.mark.parametrize("owl_type", ["owl:NamedIndividual", "owl:Class"]) +@pytest.mark.parametrize("meaning", ["ex:isLicense", "https://example.net/value", None]) +def test_instantiates_uses_the_emitted_value_resource(owl_type: str, meaning: str | None) -> None: + """Explicit and minted IRIs both carry membership, including class/individual punning.""" + schema = _INSTANTIATES_SCHEMA.replace("owl:NamedIndividual", owl_type) + schema = schema.replace(" meaning: ex:isLicense\n", f" meaning: {meaning}\n" if meaning else "") + schema = schema.replace("vocab:LicenseCategory", "https://example.net/Category") + g = OwlSchemaGenerator(schema, metaclasses=False).as_graph() + (resource,) = g.subjects(RDF.type, URIRef("https://example.net/Category")) + assert isinstance(resource, URIRef) + assert (resource, RDFS.label, Literal("isLicense")) in g + assert (resource, RDF.type, OWL[owl_type.split(":")[1]]) in g + if meaning: + assert resource == (EX.isLicense if meaning.startswith("ex:") else URIRef(meaning)) + + +def test_instantiates_rdf_schema_representation_is_distinct_from_owl_assertion() -> None: + """gen-rdf preserves the metamodel predicate; gen-owl interprets it as membership.""" + schema = _INSTANTIATES_SCHEMA.replace("imports:\n - linkml:types\n", "") + rdf = Graph().parse(data=RDFGenerator(schema).serialize(), format="turtle") + owl = OwlSchemaGenerator(schema, metaclasses=False).as_graph() + assert {str(value) for value in rdf.objects(EX.isLicense, LINKML.instantiates)} == {"vocab:LicenseCategory"} + assert (EX.isLicense, RDF.type, VOCAB.LicenseCategory) in owl From 27961233bcaaa3d2032bfa59aa29e3db11f4a503 Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 2 Oct 2026 16:52:46 +0200 Subject: [PATCH 25/30] feat(rdf): honor declared annotation ranges in OWL and SHACL Resolve annotation slots through the metaclasses declared by instantiates, including imported and inherited definitions. Use their scalar ranges to preserve the distinction between RDF nodes and literals in both generators. Replace vocabulary-name coercion with a shared declaration-driven resolver. rdfs:Resource includes literals, and URI-looking strings alone do not define an annotation's intended RDF term. Reuse URI validation, reject ambiguous or unsupported declared representations, and preserve undeclared metadata. Emit explicit annotations on local OWL types through the same path. Add functional cross-generator contracts and document explicit modelling, standards, supported metadata owners and serialization boundaries. Signed-off-by: jdsika --- docs/generators/owl.rst | 72 ++++++ .../linkml/generators/common/annotations.py | 78 +++++++ .../linkml/src/linkml/generators/owlgen.py | 13 +- .../linkml/src/linkml/generators/shaclgen.py | 5 + .../test_declared_annotations.py | 207 ++++++++++++++++++ 5 files changed, 374 insertions(+), 1 deletion(-) create mode 100644 packages/linkml/src/linkml/generators/common/annotations.py create mode 100644 tests/linkml/test_generators/test_declared_annotations.py diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst index fb65ebdb4d..48bab31c11 100644 --- a/docs/generators/owl.rst +++ b/docs/generators/owl.rst @@ -86,6 +86,78 @@ The mapping is a static, version-independent table; namespace IRIs that are not in the table are left untouched. The option is also available on ``gen-shacl`` and ``gen-jsonld-context``. +Declared annotation values +^^^^^^^^^^^^^^^^^^^^^^^^^^ + +An annotation property name does not determine whether its value is a literal +or an RDF node. For example, ``rdfs:Resource`` includes literals, and OWL permits +both IRIs and literals as annotation values. The generator does not maintain a +list of vocabulary properties whose string values should be changed into IRIs. + +Use LinkML's metamodel extension mechanism to declare the distinction. An +``instantiates`` reference identifies a metaclass whose slots describe the +annotations on that schema element. ``nodeidentifier`` denotes an IRI, CURIE or +blank node; ``string`` denotes text, even when the text looks like a URL: + +.. code-block:: yaml + + id: https://example.org/model + name: model + prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + owl: http://www.w3.org/2002/07/owl# + imports: [linkml:types] + default_prefix: ex + instantiates: [ex:OntologyMetadata] + annotations: + prior_version: ex:previous + version_label: https://example.org/a-textual-label + classes: + OntologyMetadata: + attributes: + prior_version: + slot_uri: owl:priorVersion + range: nodeidentifier + version_label: + slot_uri: owl:versionInfo + range: string + +The ontology has ``owl:priorVersion `` and +``owl:versionInfo "https://example.org/a-textual-label"``. The same mechanism +works for custom properties and annotations on classes, slots, local types, +enums and non-literal permissible values. Metaclass inheritance and imported +metaclasses are supported. Tags may be slot names, CURIEs or full slot IRIs. + +The SHACL generator uses the same conversion when its existing +``--include-annotations`` option is enabled. Those annotations describe shapes; +they do not add constraints to ordinary data instances. Schema RDF and JSON-LD +serialization retain the LinkML annotation objects and their declarations; +they do not project annotations onto ontology properties as this generator does. + +Declared scalar ranges determine term representation. ``curie`` expands to an +IRI, ``nodeidentifier`` allows IRIs and blank nodes, and XSD datatypes produce +literals. In particular, ``xsd:anyURI`` alone does not mean an IRI node. Use +``nodeidentifier`` when that distinction matters; no new generator flag is +required. Language tags apply to textual literals only. + +This is serialization of declared scalar annotations, not complete metamodel +extension validation. Structured values, non-type ranges and boolean range +expressions currently raise an error rather than guessing a term. Conflicting +metaclass declarations also raise an error. Unresolved external metaclasses and +undeclared tags retain each generator's existing behavior; a declaration must +be available locally or through a schema import to determine the RDF term. +Ordinary string-valued metamodel fields such as ``license`` retain their +metamodel representation. An explicit annotation declaration is required for +an IRI-valued alternative; avoid assigning the same property in both forms +unless both RDF values are intended. + +References: `LinkML metamodel refinement +`__, +`OWL annotation values `__, +`RDF Schema resources `__, and +`DCMI license guidance +`__. Enums and PermissibleValues ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/packages/linkml/src/linkml/generators/common/annotations.py b/packages/linkml/src/linkml/generators/common/annotations.py new file mode 100644 index 0000000000..7da1078667 --- /dev/null +++ b/packages/linkml/src/linkml/generators/common/annotations.py @@ -0,0 +1,78 @@ +"""RDF annotation terms governed by declared LinkML metamodel extensions.""" + +from typing import Any + +from rdflib import XSD, BNode, Literal, URIRef + +from linkml_runtime.linkml_model import Element, PermissibleValue, SlotDefinition +from linkml_runtime.linkml_model.types import SHEX +from linkml_runtime.utils.schemaview import SchemaView +from linkml_runtime.utils.uri_validator import validate_uri + + +def declared_annotation( + schema: SchemaView, + element: Element | PermissibleValue, + tag: str, + value: Any, + language: str | None = None, +) -> tuple[URIRef, URIRef | BNode | Literal] | None: + """Serialize a scalar annotation using an instantiated metaclass's slot. + + Match local slot names or expanded slot URIs in the induced metaclass, + including inherited and imported definitions. Unresolved external metaclasses + and undeclared tags leave the caller's existing behavior unchanged. Conflicting + declarations fail instead of selecting a range according to iteration order. + + This is RDF serialization, not annotation constraint validation. The + metamodel-extension contract is described in LinkML specification section 5, + ``Metamodel refinement using annotations``. + """ + declarations: list[SlotDefinition] = [] + for metaclass in element.instantiates: + identifier = schema.expand_curie(metaclass) + for cls in schema.all_classes().values(): + if schema.get_uri(cls, expand=True) != identifier: + continue + for slot in schema.class_induced_slots(cls.name): + if tag == slot.name or schema.expand_curie(tag) == schema.get_uri(slot, expand=True): + declarations.append(slot) + if not declarations: + return None + terms = { + (URIRef(schema.get_uri(slot, expand=True)), _annotation_value(schema, slot, value, language)) + for slot in declarations + } + if len(terms) != 1: + raise ValueError(f"Conflicting metaclass declarations for annotation {tag!r}") + return terms.pop() + + +def _annotation_value( + schema: SchemaView, slot: SlotDefinition, value: Any, language: str | None +) -> URIRef | BNode | Literal: + """Convert a declared scalar range to its RDF term without vocabulary guessing.""" + if slot.any_of or slot.all_of or slot.exactly_one_of or slot.none_of or slot.range_expression: + raise ValueError(f"Annotation {slot.name!r} requires a single scalar range for RDF serialization") + if not isinstance(value, str | bool | int | float): + raise ValueError(f"Annotation {slot.name!r} requires a scalar RDF value") + if slot.range not in schema.all_types(): + raise ValueError(f"Annotation {slot.name!r} has unsupported non-scalar range {slot.range!r}") + typ = schema.induced_type(slot.range) + datatype = URIRef(schema.expand_curie(typ.uri)) if typ.uri else None + # The standard curie type requires expansion in RDF even though its lexical + # datatype is xsd:string. nodeidentifier explicitly denotes a non-literal; + # xsd:anyURI alone does not distinguish an IRI node from a URI literal. + curie = datatype == XSD.string and "curie" in schema.type_ancestors(slot.range) + if datatype in (SHEX.iri, SHEX.nonLiteral) or curie: + if not isinstance(value, str): + raise ValueError(f"Annotation {slot.name!r} requires a node identifier") + if datatype == SHEX.nonLiteral and value.startswith("_:") and len(value) > 2: + return BNode(value[2:]) + expanded = schema.expand_curie(value) + if not validate_uri(expanded): + raise ValueError(f"Annotation {slot.name!r} requires an absolute IRI or declared CURIE: {value!r}") + return URIRef(expanded) + if datatype in (None, XSD.string): + return Literal(value, lang=language if isinstance(value, str) else None) + return Literal(value, datatype=datatype) diff --git a/packages/linkml/src/linkml/generators/owlgen.py b/packages/linkml/src/linkml/generators/owlgen.py index 36db84b3ca..5bd8ac47cf 100644 --- a/packages/linkml/src/linkml/generators/owlgen.py +++ b/packages/linkml/src/linkml/generators/owlgen.py @@ -19,6 +19,7 @@ from linkml import METAMODEL_NAMESPACE_NAME from linkml._version import __version__ +from linkml.generators.common.annotations import declared_annotation from linkml.generators.common.subproperty import is_xsd_anyuri_range from linkml.utils.deprecation import deprecation_warning from linkml.utils.generator import Generator, normalize_graph_prefixes, shared_arguments @@ -431,9 +432,19 @@ def add_metadata(self, e: Element | PermissibleValue, uri: URIRef) -> None: obj = Literal(v) self.graph.add((uri, metaslot_uri, obj)) + self._add_annotations(e, uri) + + def _add_annotations(self, e: Element | PermissibleValue, uri: URIRef) -> None: + """Emit explicit annotations on any generated schema resource.""" + this_sv = self.schemaview + lang = self._resolve_language(e) for k, v in e.annotations.items(): if isinstance(v, dict) or isinstance(v, list): continue + declared = declared_annotation(this_sv, e, k, v.value, lang) + if declared is not None: + self.graph.add((uri, *declared)) + continue if ":" not in k: default_prefix = this_sv.schema.default_prefix if default_prefix in this_sv.schema.prefixes: @@ -1106,7 +1117,7 @@ def add_type(self, typ: TypeDefinition) -> None: return self._add_instantiates(typ, type_uri) - + self._add_annotations(typ, type_uri) if self.metaclasses: self.graph.add( ( diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 1f6e3f6255..9841a87688 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -14,6 +14,7 @@ from linkml._version import __version__ from linkml.generators.common.class_expression import value_bounds +from linkml.generators.common.annotations import declared_annotation from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range from linkml.generators.shacl.shacl_data_type import ShaclDataType from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor @@ -1859,6 +1860,10 @@ def _add_annotations(self, func: Callable, item) -> None: if type(annotations) is JsonObj: annotations = as_dict(annotations) for a in annotations.values(): + declared = declared_annotation(sv, item, a["tag"], a["value"], self._resolve_language(item)) + if declared is not None: + func(*declared) + continue # If ':' is in the tag, treat it as a CURIE, otherwise string Literal if ":" in a["tag"]: N_predicate = URIRef(sv.expand_curie(a["tag"])) diff --git a/tests/linkml/test_generators/test_declared_annotations.py b/tests/linkml/test_generators/test_declared_annotations.py new file mode 100644 index 0000000000..697d60f19c --- /dev/null +++ b/tests/linkml/test_generators/test_declared_annotations.py @@ -0,0 +1,207 @@ +"""Declared annotation ranges determine RDF terms independently of vocabulary.""" + +from pathlib import Path + +import pytest +import yaml +from pyshacl import validate +from rdflib import RDF, SH, XSD, BNode, Graph, Literal, URIRef + +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator + +EX = "https://example.org/" +SCHEMA = """ +id: https://example.org/model +name: model +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ +imports: [linkml:types] +default_prefix: ex +types: + Reference: + typeof: nodeidentifier + Text: + typeof: string +classes: + Metadata: + class_uri: ex:Metadata + attributes: + reference: + slot_uri: ex:reference + range: Reference + text: + slot_uri: ex:text + range: Text + Profile: + is_a: Metadata + class_uri: ex:Profile + Thing: + instantiates: [ex:Profile] + annotations: + ex:reference: ex:target + ex:text: https://example.org/literal-text +""" + + +def _graph(source: str | Path, generator: str, **kwargs: object) -> Graph: + """Exercise generated Turtle, including term preservation after parsing.""" + if generator == "owl": + output = OwlSchemaGenerator(source, **kwargs).serialize() + else: + output = ShaclGenerator(source, include_annotations=True, **kwargs).serialize() + return Graph().parse(data=output, format="turtle") + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("tag_form", ["local", "curie", "iri"]) +@pytest.mark.parametrize("value", ["ex:target", "https://example.net/target", "urn:example:target"]) +def test_declared_ranges(generator: str, tag_form: str, value: str) -> None: + """Inherited declarations handle arbitrary predicates, CURIEs and absolute IRIs.""" + schema = yaml.safe_load(SCHEMA) + prefix = {"local": "", "curie": "ex:", "iri": EX}[tag_form] + schema["classes"]["Thing"]["annotations"] = { + prefix + "reference": value, + prefix + "text": EX + "literal-text", + } + graph = _graph(yaml.safe_dump(schema), generator) + expected = URIRef(EX + "target" if value == "ex:target" else value) + assert list(graph.objects(None, URIRef(EX + "reference"))) == [expected] + assert list(graph.objects(None, URIRef(EX + "text"))) == [Literal(EX + "literal-text")] + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("owner", ["class", "slot", "type"]) +def test_metadata_owners(generator: str, owner: str) -> None: + """Every annotated shape/resource uses the same declared term conversion.""" + schema = yaml.safe_load(SCHEMA) + metadata = {"instantiates": ["ex:Profile"], "annotations": {"ex:reference": "ex:target"}} + schema["classes"]["Thing"].pop("annotations") + if owner == "class": + schema["classes"]["Thing"].update(metadata) + elif owner == "slot": + schema["classes"]["Thing"]["attributes"] = {"value": {"range": "string", **metadata}} + else: + schema["types"]["Text"].update(metadata) + schema["classes"]["Thing"]["attributes"] = {"value": {"range": "Text"}} + graph = _graph(yaml.safe_dump(schema), generator) + assert URIRef(EX + "target") in graph.objects(None, URIRef(EX + "reference")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_imported_metaclass(tmp_path: Path, generator: str) -> None: + """Imported definitions are resolved by class URI, including inherited slots.""" + profile = yaml.safe_load(SCHEMA) + del profile["classes"]["Thing"] + (tmp_path / "profile.yaml").write_text(yaml.safe_dump(profile)) + schema = yaml.safe_load(SCHEMA) + schema["id"] = EX + "consumer" + schema["imports"] = ["profile"] + del schema["types"] + schema["classes"] = {"Thing": schema["classes"]["Thing"]} + source = tmp_path / "consumer.yaml" + source.write_text(yaml.safe_dump(schema)) + assert URIRef(EX + "target") in _graph(source, generator).objects(None, URIRef(EX + "reference")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("value", ["plain text", "relative/path", "https://example.org/%ZZ", 42]) +def test_invalid_node_identifier_fails(generator: str, value: object) -> None: + """A declared node cannot silently become a literal or an invalid RDF IRI.""" + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Thing"]["annotations"]["ex:reference"] = value + with pytest.raises(ValueError, match="requires (an absolute IRI|a node identifier)"): + _graph(yaml.safe_dump(schema), generator) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_blank_node_and_language(generator: str) -> None: + """Explicit node identifiers allow blank nodes; only text receives a language tag.""" + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Thing"]["annotations"]["ex:reference"] = "_:target" + graph = _graph(yaml.safe_dump(schema), generator, default_language="en") + assert isinstance(next(graph.objects(None, URIRef(EX + "reference"))), BNode) + assert Literal(EX + "literal-text", lang="en") in graph.objects(None, URIRef(EX + "text")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_conflicting_metaclasses_fail(generator: str) -> None: + """Different RDF terms cannot be selected by the order of metaclass declarations.""" + schema = yaml.safe_load(SCHEMA) + schema["classes"]["Other"] = {"attributes": {"reference": {"slot_uri": "ex:reference", "range": "string"}}} + schema["classes"]["Thing"]["instantiates"].append("ex:Other") + with pytest.raises(ValueError, match="Conflicting metaclass"): + _graph(yaml.safe_dump(schema), generator) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_uri_literal_is_distinct_from_node(generator: str) -> None: + """The xsd:anyURI datatype does not by itself require an IRI node.""" + schema = yaml.safe_load(SCHEMA) + schema["types"]["Text"]["uri"] = "xsd:anyURI" + graph = _graph(yaml.safe_dump(schema), generator) + assert Literal(EX + "literal-text", datatype=XSD.anyURI) in graph.objects(None, URIRef(EX + "text")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +def test_curie_range_requires_expansion(generator: str) -> None: + """The standard curie type requires expansion in RDF despite its string datatype.""" + schema = yaml.safe_load(SCHEMA) + schema["types"]["Reference"]["typeof"] = "curie" + graph = _graph(yaml.safe_dump(schema), generator) + assert URIRef(EX + "target") in graph.objects(None, URIRef(EX + "reference")) + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("unsupported", ["structured", "class", "union"]) +def test_unsupported_declared_representation_fails(generator: str, unsupported: str) -> None: + """Unsupported values and mixed range expressions are not silently stringified.""" + schema = yaml.safe_load(SCHEMA) + slot = schema["classes"]["Metadata"]["attributes"]["reference"] + if unsupported == "structured": + schema["classes"]["Thing"]["annotations"]["ex:reference"] = {"value": {"nested": "value"}} + elif unsupported == "class": + slot["range"] = "Thing" + else: + slot["any_of"] = [{"range": "nodeidentifier"}, {"range": "string"}] + with pytest.raises(ValueError, match="scalar"): + _graph(yaml.safe_dump(schema), generator) + + +def test_untyped_metadata_preserves_literal_values() -> None: + """OWL does not infer RDF node kinds from familiar vocabulary names or URL text.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/" + schema["license"] = "https://example.org/license" + schema["annotations"] = {"dcterms:license": "SPDX:MIT"} + graph = _graph(yaml.safe_dump(schema), "owl") + predicate = URIRef("http://purl.org/dc/terms/license") + assert set(graph.objects(URIRef(EX + "model"), predicate)) == { + Literal("https://example.org/license"), + Literal("SPDX:MIT"), + } + + +def test_owl_header_enum_and_permissible_value() -> None: + """OWL annotation declarations also apply to the ontology and vocabulary resources.""" + schema = yaml.safe_load(SCHEMA) + metadata = {"instantiates": ["ex:Profile"], "annotations": {"ex:reference": "ex:target"}} + schema.update(metadata) + schema["enums"] = {"Choice": {**metadata, "permissible_values": {"A": {"meaning": "ex:A", **metadata}}}} + graph = _graph(yaml.safe_dump(schema), "owl") + for subject in [EX + "model", EX + "Choice", EX + "A"]: + assert (URIRef(subject), URIRef(EX + "reference"), URIRef(EX + "target")) in graph + + +def test_annotations_do_not_constrain_data() -> None: + """Metadata appears on a shape without becoming a required data property.""" + graph = _graph(SCHEMA, "shacl") + shape = next(graph.subjects(SH.targetClass, URIRef(EX + "Thing"))) + assert (shape, URIRef(EX + "reference"), URIRef(EX + "target")) in graph + for prop in graph.objects(shape, SH.property): + assert (prop, SH.path, URIRef(EX + "reference")) not in graph + assert (shape, RDF.type, SH.NodeShape) in graph + data = Graph() + data.add((URIRef(EX + "instance"), RDF.type, URIRef(EX + "Thing"))) + assert validate(data, shacl_graph=graph, meta_shacl=True)[0] From 4190754a900943110d998d5e11410b91f7d9dc7f Mon Sep 17 00:00:00 2001 From: jdsika Date: Fri, 2 Oct 2026 16:47:40 +0200 Subject: [PATCH 26/30] fix(rdf): preserve string-derived anyURI literals across generators A string-derived type with uri: xsd:anyURI holds URI-reference data, including relative paths. Preserve its typed literal in JSON-LD contexts, SHACL, ShEx and runtime RDF serialization, matching OWL datatype mode. Resolve type ancestry and the effective expanded datatype together so inherited types, prefix aliases and explicit datatype overrides agree. Legacy generators inspect a copy of their resolved schema instead of reopening source files. Preserve the existing JSON-LD/OWL IRI option and keep the runtime independent of a new compiler/runtime shared API. Exercise actual JSON-LD expansion, Python-to-RDF serialization, SHACL and ShEx validation, including negative RDF-term substitutions. Test mixed class/literal JSON-LD and SHACL ranges; general ShEx any_of remains a documented pre-existing limitation outside this correction. Signed-off-by: jdsika --- docs/generators/jsonld-context.rst | 39 +++ .../linkml/generators/common/subproperty.py | 55 ++-- .../src/linkml/generators/jsonldcontextgen.py | 48 +++- .../linkml/src/linkml/generators/shaclgen.py | 39 +-- .../linkml/src/linkml/generators/shexgen.py | 17 +- .../linkml_runtime/dumpers/rdflib_dumper.py | 16 +- .../test_anyuri_literal_types.py | 253 ++++++++++++++++++ .../test_generators/test_jsonldcontextgen.py | 4 +- .../test_rdflib_dumper.py | 27 +- 9 files changed, 417 insertions(+), 81 deletions(-) create mode 100644 tests/linkml/test_generators/test_anyuri_literal_types.py diff --git a/docs/generators/jsonld-context.rst b/docs/generators/jsonld-context.rst index 86d493c66c..3ddc6f7631 100644 --- a/docs/generators/jsonld-context.rst +++ b/docs/generators/jsonld-context.rst @@ -85,6 +85,45 @@ to gen-prefix-map: gen-prefix-map --flatprefixes personinfo.yaml > personinfo.prefixmap.json +URIs as IRIs or as literals +--------------------------- + +By default a ``uri`` or ``uriorcurie`` slot is coerced to ``xsd:anyURI``, a typed +literal. ``--xsd-anyuri-as-iri`` coerces it to ``@id`` instead, so the value becomes an +IRI node, matching the ``sh:nodeKind sh:IRI`` the SHACL generator emits. The OWL +generator accepts the same flag. + +The flag applies to ``uri``, ``uriorcurie`` and their derived types when the +effective datatype remains ``xsd:anyURI``. Datatype IRIs are expanded before +comparison, so full IRIs and alternative prefixes behave identically. A type +derived from ``string`` that declares ``uri: xsd:anyURI`` stays a typed literal with or +without it. Use one for a URI reference that is data rather than a link, such as a file +path that may be relative: as ``@id`` it would be resolved against the document base. + +.. code-block:: yaml + + types: + FilePath: + typeof: string + uri: xsd:anyURI # always "@type": "xsd:anyURI", sh:datatype xsd:anyURI + slots: + homepage: + range: uri # "@id" with --xsd-anyuri-as-iri + file_path: + range: FilePath + +The RDF dumper, SHACL and ShEx generators distinguish the same URI family from +literal-valued types. An explicit datatype override such as ``uri: xsd:string`` +remains literal-valued, including when the type inherits from ``uri``. + +`XSD anyURI `__ admits relative URI +references as literal values. This differs from an RDF IRI node: `JSON-LD type +coercion `__ with ``@id`` interprets +strings as identifiers and resolves relative references against the base IRI. +The existing option selects the JSON-LD/OWL representation of LinkML's URI +family; it is not needed to preserve a deliberately literal-valued type. No +additional option is introduced. + Docs ---- diff --git a/packages/linkml/src/linkml/generators/common/subproperty.py b/packages/linkml/src/linkml/generators/common/subproperty.py index 9b136e2429..d78c8e3f09 100644 --- a/packages/linkml/src/linkml/generators/common/subproperty.py +++ b/packages/linkml/src/linkml/generators/common/subproperty.py @@ -7,6 +7,8 @@ - Collecting and deduplicating slot hierarchy values """ +from rdflib.namespace import XSD + from linkml_runtime.linkml_model.meta import SlotDefinition from linkml_runtime.utils.formatutils import underscore from linkml_runtime.utils.schemaview import SchemaView @@ -15,9 +17,27 @@ CURIE_TYPES: frozenset[str] = frozenset({"uriorcurie", "curie"}) URI_TYPES: frozenset[str] = frozenset({"uri"}) -# Types whose XSD mapping is xsd:anyURI (not xsd:string). -# ``curie`` maps to xsd:string and is deliberately excluded. -_ANYURI_TYPES: frozenset[str] = frozenset({"uri", "uriorcurie"}) + +def is_xsd_anyuri_range(sv: SchemaView, range_type: str | None) -> bool: + """Whether a URI-family type's effective datatype is ``xsd:anyURI``. + + LinkML's standard ``uri`` and ``uriorcurie`` types and their descendants + participate in the existing literal-to-IRI option. String-derived types do + not. Resolve the datatype before comparing so prefix aliases and explicit + datatype overrides have the same meaning in every generator. + + The roots are defined at https://w3id.org/linkml/types. The runtime dumper + implements the same small rule independently because the two packages may + be released separately; real serialization tests check their agreement. + """ + if range_type not in sv.all_types(): + return False + typ = sv.induced_type(range_type) + return ( + bool(typ.uri) + and sv.expand_curie(typ.uri) == str(XSD.anyURI) + and any(ancestor in ("uri", "uriorcurie") for ancestor in sv.type_ancestors(range_type)) + ) def is_uri_range(sv: SchemaView, range_type: str | None) -> bool: @@ -67,35 +87,6 @@ def is_curie_range(sv: SchemaView, range_type: str | None) -> bool: return False -def is_xsd_anyuri_range(sv: SchemaView, range_type: str | None) -> bool: - """Check if range type resolves to ``xsd:anyURI``. - - Returns True for ``uri``, ``uriorcurie``, and types that inherit from them. - Returns False for ``curie`` (which maps to ``xsd:string``). - - This is the correct predicate for the ``--xsd-anyuri-as-iri`` flag: only - types whose XSD representation is ``xsd:anyURI`` should be promoted from - literal to IRI semantics. ``curie`` is a compact string representation - that resolves to ``xsd:string`` and must not be affected. - - :param sv: SchemaView for type ancestry lookup - :param range_type: The range type to check - :return: True if range type maps to xsd:anyURI - """ - if range_type is None: - return False - - if range_type in _ANYURI_TYPES: - return True - - if range_type in sv.all_types(): - type_ancestors = set(sv.type_ancestors(range_type)) - if type_ancestors & _ANYURI_TYPES: - return True - - return False - - def format_slot_value_for_range(sv: SchemaView, slot_name: str, range_type: str | None) -> str: """ Format slot value according to the declared range type. diff --git a/packages/linkml/src/linkml/generators/jsonldcontextgen.py b/packages/linkml/src/linkml/generators/jsonldcontextgen.py index 56ed6a011b..f6ea7072d7 100644 --- a/packages/linkml/src/linkml/generators/jsonldcontextgen.py +++ b/packages/linkml/src/linkml/generators/jsonldcontextgen.py @@ -5,6 +5,7 @@ import json import os import re +from copy import deepcopy from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -14,19 +15,16 @@ from rdflib import SKOS, XSD, Namespace from linkml._version import __version__ +from linkml.generators.common.subproperty import is_xsd_anyuri_range from linkml.utils.deprecation import deprecated_fields from linkml.utils.generator import Generator, shared_arguments, well_known_prefix_map -from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, SlotDefinition +from linkml_runtime.linkml_model.meta import ClassDefinition, EnumDefinition, Prefix, SlotDefinition from linkml_runtime.linkml_model.types import SHEX from linkml_runtime.utils.formatutils import camelcase, underscore from linkml_runtime.utils.schemaview import SchemaView URI_RANGES = (SHEX.nonliteral, SHEX.bnode, SHEX.iri) -# Extended URI_RANGES that also treats xsd:anyURI as an IRI reference (@id) -# rather than a typed literal. Opt-in via --xsd-anyuri-as-iri flag. -URI_RANGES_WITH_XSD = (*URI_RANGES, XSD.anyURI) - ENUM_CONTEXT = { "text": "skos:notation", "description": "skos:prefLabel", @@ -83,8 +81,11 @@ class ContextGenerator(Generator): """Map xsd:anyURI-typed ranges (uri, uriorcurie) to ``@type: @id`` instead of ``@type: xsd:anyURI``. This aligns the JSON-LD context with the SHACL generator, which emits - ``sh:nodeKind sh:IRI`` for the same types. + ``sh:nodeKind sh:IRI`` for the same types. Types derived from ``uri`` or + ``uriorcurie`` follow them; a type derived from ``string`` that declares + ``uri: xsd:anyURI`` stays a typed literal, as in the OWL generator. """ + _type_view_cache: SchemaView | None = field(default=None, repr=False) # Framing (opt-in via CLI flag) emit_frame: bool = False @@ -370,7 +371,6 @@ def _literal_coercion_for_ranges(self, ranges: list[str]) -> tuple[bool, str | N and "could not resolve safely because the branches disagree". """ coercions: set[str | None] = set() - uri_ranges = URI_RANGES_WITH_XSD if self.xsd_anyuri_as_iri else URI_RANGES for range_name in ranges: if range_name not in self.schema.types: continue @@ -379,7 +379,7 @@ def _literal_coercion_for_ranges(self, ranges: list[str]) -> tuple[bool, str | N range_uri = self.namespaces.uri_for(range_type.uri) if range_uri == XSD.string: coercions.add(None) - elif range_uri in uri_ranges: + elif self._coerces_to_id(range_name): coercions.add("@id") else: coercions.add(range_type.uri) @@ -390,6 +390,35 @@ def _literal_coercion_for_ranges(self, ranges: list[str]) -> tuple[bool, str | N return True, next(iter(coercions)) return False, None + def _coerces_to_id(self, range_name: str) -> bool: + """Whether values of the LinkML type *range_name* are IRIs (``@type: @id``) rather than literals. + + The ShEx node types always are. ``xsd:anyURI`` is, with ``--xsd-anyuri-as-iri``, only + for ``uri``, ``uriorcurie`` and types derived from them, the same rule the OWL + generator applies (:func:`is_xsd_anyuri_range`). A type derived from ``string`` that + declares ``uri: xsd:anyURI`` is a typed literal: a URI reference kept as a string, which + must not be resolved against the document base. + """ + range_uri = self.namespaces.uri_for(self.schema.types[range_name].uri) + if range_uri in URI_RANGES: + return True + return self.xsd_anyuri_as_iri and range_uri == XSD.anyURI and is_xsd_anyuri_range(self._type_view(), range_name) + + def _type_view(self) -> SchemaView: + """A SchemaView of the schema, for type ancestry; built once when the generator has none.""" + if self.schemaview is not None: + return self.schemaview + if self._type_view_cache is None: + # SchemaLoader already resolved the imports; source_file is only + # provenance and may no longer name an available file. + type_schema = deepcopy(self.schema) + type_schema.imports = [] + for prefix, namespace in self.namespaces.items(): + if not prefix.startswith("@"): + type_schema.prefixes[prefix] = Prefix(prefix, str(namespace)) + self._type_view_cache = SchemaView(type_schema) + return self._type_view_cache + def _vocab_eligible_enum(self, enum: EnumDefinition) -> tuple[bool, str | None]: """Check if an enum qualifies for ``@type: @vocab`` context generation. @@ -485,10 +514,9 @@ def visit_slot(self, aliased_slot_name: str, slot: SlotDefinition) -> None: self.emit_prefixes.add(skos) else: range_type = self.schema.types[slot.range] - uri_ranges = URI_RANGES_WITH_XSD if self.xsd_anyuri_as_iri else URI_RANGES if self.namespaces.uri_for(range_type.uri) == XSD.string: pass - elif self.namespaces.uri_for(range_type.uri) in uri_ranges: + elif self._coerces_to_id(slot.range): slot_def["@type"] = "@id" else: slot_def["@type"] = range_type.uri diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 9841a87688..bb8019b3ee 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -15,7 +15,7 @@ from linkml._version import __version__ from linkml.generators.common.class_expression import value_bounds from linkml.generators.common.annotations import declared_annotation -from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range +from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range, is_xsd_anyuri_range from linkml.generators.shacl.shacl_data_type import ShaclDataType from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor from linkml.utils.generator import Generator, normalize_graph_prefixes, shared_arguments @@ -32,6 +32,7 @@ SlotDefinition, SlotExpression, ) +from linkml_runtime.linkml_model.types import SHEX from linkml_runtime.utils.formatutils import underscore from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph from linkml_runtime.utils.yamlutils import TypedNode, extended_float, extended_int, extended_str @@ -1758,37 +1759,23 @@ def _add_enum(self, g: Graph, func: Callable, r: ElementName) -> None: ) func(SH["in"], pv_node) - # Type URIs denoting non-literal (IRI or blank-node) values. - # SHACL §4.8.1 - # defines sh:IRI, sh:BlankNode, and sh:BlankNodeOrIRI as valid node kinds. - # These URIs map to sh:IRI or sh:BlankNodeOrIRI constraints (never sh:Literal). - _NON_LITERAL_TYPE_URIS = frozenset( - { - "xsd:anyURI", # uri, uriorcurie → sh:IRI - "http://www.w3.org/ns/shex#nonLiteral", # nodeidentifier → sh:BlankNodeOrIRI - "http://www.w3.org/ns/shex#iri", # future-proofing → sh:IRI - } - ) - # IRI-only subset: uri/uriorcurie must be strict IRI references (sh:IRI), - # while nodeidentifier (shex:nonLiteral) allows blank nodes too (sh:BlankNodeOrIRI). - # See RDF 1.1 §3.2–3.3 . - _IRI_ONLY_TYPE_URIS = frozenset( - { - "xsd:anyURI", - } - ) - def _add_type(self, func: Callable, r: ElementName) -> None: sv = self.schemaview # Types can inherit URI and pattern constraints. rt = sv.induced_type(r) type_uri = rt.uri expanded = sv.get_uri(rt, expand=True) if type_uri else None - if type_uri and (type_uri in self._NON_LITERAL_TYPE_URIS or expanded in self._NON_LITERAL_TYPE_URIS): - if type_uri in self._IRI_ONLY_TYPE_URIS: - func(SH.nodeKind, SH.IRI) - else: - func(SH.nodeKind, SH.BlankNodeOrIRI) + # Resolve vocabulary IRIs before selecting RDF node kinds. The ShEx + # vocabulary distinguishes IRI-only and non-literal (IRI or blank) nodes. + node_kind = None + if expanded == str(SHEX.iri): + node_kind = SH.IRI + elif expanded == str(SHEX.nonLiteral): + node_kind = SH.BlankNodeOrIRI + elif expanded == str(XSD.anyURI) and is_xsd_anyuri_range(sv, r): + node_kind = SH.IRI + if node_kind is not None: + func(SH.nodeKind, node_kind) elif type_uri: func(SH.nodeKind, SH.Literal) func(SH.datatype, URIRef(sv.get_uri(rt, expand=True))) diff --git a/packages/linkml/src/linkml/generators/shexgen.py b/packages/linkml/src/linkml/generators/shexgen.py index e74e45225e..12c9ac5dc7 100644 --- a/packages/linkml/src/linkml/generators/shexgen.py +++ b/packages/linkml/src/linkml/generators/shexgen.py @@ -2,23 +2,25 @@ import os import urllib.parse as urlparse +from copy import deepcopy from dataclasses import dataclass, field import click from jsonasobj import as_json as as_json_1 -from rdflib import OWL, RDF, XSD, Graph, Namespace +from rdflib import OWL, RDF, Graph, Namespace from ShExJSG import ShExC from ShExJSG.SchemaWithContext import Schema from ShExJSG.ShExJ import IRIREF, EachOf, NodeConstraint, Shape, ShapeOr, TripleConstraint from linkml import METAMODEL_NAMESPACE, METAMODEL_NAMESPACE_NAME from linkml._version import __version__ -from linkml.generators.common.subproperty import get_subproperty_values +from linkml.generators.common.subproperty import get_subproperty_values, is_xsd_anyuri_range from linkml.utils.generator import Generator, shared_arguments from linkml_runtime.linkml_model.meta import ( ClassDefinition, ElementName, EnumDefinition, + Prefix, SlotDefinition, SlotDefinitionName, TypeDefinition, @@ -27,6 +29,7 @@ from linkml_runtime.utils.formatutils import camelcase, sfx from linkml_runtime.utils.metamodelcore import URIorCURIE from linkml_runtime.utils.rdf_canonicalize import canonicalize_rdf_graph +from linkml_runtime.utils.schemaview import SchemaView @dataclass @@ -70,12 +73,20 @@ def visit_schema(self, **_): # Adjust the schema context to include the base model URI context = self.shex["@context"] self.shex["@context"] = [context, {"@base": self.namespaces._base}] + # SchemaLoader has already resolved imports. Inspect a copy of that + # effective schema without reopening source files or changing provenance. + type_schema = deepcopy(self.schema) + type_schema.imports = [] + for prefix, namespace in self.namespaces.items(): + if not prefix.startswith("@"): + type_schema.prefixes[prefix] = Prefix(prefix, str(namespace)) + type_view = SchemaView(type_schema) # Emit all of the type definitions for typ in self.schema.types.values(): model_uri = self._class_or_type_uri(typ) if typ.uri: typ_type_uri = self.namespaces.uri_for(typ.uri) - if typ_type_uri in (XSD.anyURI, SHEX.iri): + if typ_type_uri == SHEX.iri or is_xsd_anyuri_range(type_view, typ.name): self.shapes.append(NodeConstraint(id=model_uri, nodeKind="iri")) elif typ_type_uri == SHEX.nonLiteral: self.shapes.append(NodeConstraint(id=model_uri, nodeKind="nonliteral")) diff --git a/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py b/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py index 177186051a..8c3da30dca 100644 --- a/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py +++ b/packages/linkml_runtime/src/linkml_runtime/dumpers/rdflib_dumper.py @@ -5,7 +5,7 @@ from curies import Converter from pydantic import BaseModel from rdflib import XSD, Graph, URIRef -from rdflib.namespace import RDF +from rdflib.namespace import RDF, RDFS from rdflib.term import BNode, Literal, Node from linkml_runtime.dumpers.dumper_root import Dumper @@ -101,17 +101,19 @@ def inject_triples( else: return Literal(element.text) if target_type in schemaview.all_types(): - t = schemaview.get_type(target_type) + t = schemaview.induced_type(target_type) dt_uri = t.uri if dt_uri: - if dt_uri in ("rdfs:Resource", "xsd:anyURI"): + if "xsd" not in namespaces: + namespaces["xsd"] = XSD + datatype = namespaces.uri_for(dt_uri) + uri_ancestry = any(name in ("uri", "uriorcurie") for name in schemaview.type_ancestors(target_type)) + if datatype == RDFS.Resource or (datatype == XSD.anyURI and uri_ancestry): return URIRef(schemaview.expand_curie(element)) - elif dt_uri == "xsd:string": + elif datatype == XSD.string: return Literal(element) else: - if "xsd" not in namespaces: - namespaces["xsd"] = XSD - return Literal(element, datatype=namespaces.uri_for(dt_uri)) + return Literal(element, datatype=datatype) else: logger.warning(f"No datatype specified for : {t.name}, using plain Literal") return Literal(element) diff --git a/tests/linkml/test_generators/test_anyuri_literal_types.py b/tests/linkml/test_generators/test_anyuri_literal_types.py new file mode 100644 index 0000000000..06a83b898f --- /dev/null +++ b/tests/linkml/test_generators/test_anyuri_literal_types.py @@ -0,0 +1,253 @@ +"""``xsd:anyURI``: an IRI for ``uri`` and ``uriorcurie``, a typed literal for types derived from ``string``. + +A schema can need both behaviours. A slot that points to a resource is an IRI node; a slot +that holds a URI reference as data - a relative file path such as ``./data/file.png`` - must +stay a literal, because JSON-LD resolves an ``@id`` against the document base and turns the +path into a machine-specific absolute IRI. The second is a type derived from ``string`` that +declares ``uri: xsd:anyURI``. + +The OWL generator already decides by type ancestry (:func:`is_xsd_anyuri_range`). These tests +pin the JSON-LD context generator, the SHACL generator and the RDF dumper to the same rule. +""" + +import json + +import pytest +import yaml +from pyshacl import validate +from pyshex import ShExEvaluator +from rdflib import OWL, RDF, SH, XSD, BNode, Graph, Literal, URIRef +from rdflib.compare import isomorphic + +from linkml.generators.jsonldcontextgen import ContextGenerator +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.pythongen import PythonGenerator +from linkml.generators.shaclgen import ShaclGenerator +from linkml.generators.shexgen import ShExGenerator +from linkml_runtime.dumpers import rdflib_dumper +from linkml_runtime.utils.compile_python import compile_python +from linkml_runtime.utils.schemaview import SchemaView + +SCHEMA = """ +id: https://example.org/anyuri +name: anyuri +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ +imports: + - linkml:types +default_prefix: ex +default_range: string + +types: + FilePath: + typeof: string + uri: xsd:anyURI + description: A URI reference kept as a literal; it may be relative. + Homepage: + typeof: uri + description: A uri by derivation, so an IRI. + +slots: + id: + identifier: true + range: uriorcurie + link: + range: uri + slot_uri: ex:link + homepage: + range: Homepage + slot_uri: ex:homepage + path: + range: FilePath + slot_uri: ex:path + +classes: + Resource: + slots: [id, link, homepage, path] +""" + +EX = "https://example.org/" + + +@pytest.mark.parametrize("xsd_anyuri_as_iri", [False, True]) +def test_context_keeps_string_derived_anyuri_a_literal(xsd_anyuri_as_iri: bool) -> None: + """The existing option promotes only the URI family, not literal-valued types.""" + ctx = json.loads(ContextGenerator(SCHEMA, xsd_anyuri_as_iri=xsd_anyuri_as_iri).serialize())["@context"] + assert ctx["path"]["@type"] == "xsd:anyURI" + expected_iri = "@id" if xsd_anyuri_as_iri else "xsd:anyURI" + assert ctx["link"]["@type"] == expected_iri + assert ctx["homepage"]["@type"] == expected_iri + + +ANY_OF_SCHEMA = """ +id: https://example.org/anyuri-any-of +name: anyuri_any_of +prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ +imports: + - linkml:types +default_prefix: ex +default_range: string +types: + FilePath: + typeof: string + uri: xsd:anyURI +slots: + either_path: + slot_uri: ex:eitherPath + any_of: + - range: Target + - range: FilePath + either_uri: + slot_uri: ex:eitherUri + any_of: + - range: Target + - range: uri +classes: + Target: + class_uri: ex:Target + Holder: + slots: [either_path, either_uri] +""" + + +def test_context_any_of_with_string_derived_anyuri_is_not_promoted() -> None: + """A mixed union coerces scalar values to literals; class values can use explicit node objects.""" + ctx = json.loads(ContextGenerator(ANY_OF_SCHEMA, xsd_anyuri_as_iri=True).serialize())["@context"] + assert ctx["either_path"]["@type"] == "xsd:anyURI" + # a uri branch agrees with the class branch, as before + assert ctx["either_uri"]["@type"] == "@id" + + +def _property_shape(g: Graph, path: str) -> URIRef | BNode: + """Find the generated property shape for an example property.""" + return next(ps for ps in g.subjects(SH.path, URIRef(EX + path))) + + +def test_shacl_string_derived_anyuri_is_a_typed_literal() -> None: + """SHACL preserves literal types while retaining URI-family node kinds.""" + g = Graph().parse(data=ShaclGenerator(SCHEMA, mergeimports=True).serialize(), format="turtle") + + path = _property_shape(g, "path") + assert g.value(path, SH.nodeKind) == SH.Literal + assert g.value(path, SH.datatype) == XSD.anyURI + + for iri_slot in ("link", "homepage"): + ps = _property_shape(g, iri_slot) + assert g.value(ps, SH.nodeKind) == SH.IRI, iri_slot + assert g.value(ps, SH.datatype) is None, iri_slot + + +@pytest.mark.parametrize("xsd_anyuri_as_iri", [False, True]) +def test_owl_string_derived_anyuri_is_a_datatype_property(xsd_anyuri_as_iri: bool) -> None: + """OWL and JSON-LD use the same option boundary.""" + g = OwlSchemaGenerator(SCHEMA, xsd_anyuri_as_iri=xsd_anyuri_as_iri, type_objects=False).as_graph() + assert (URIRef(EX + "path"), RDF.type, OWL.DatatypeProperty) in g + expected = OWL.ObjectProperty if xsd_anyuri_as_iri else OWL.DatatypeProperty + assert (URIRef(EX + "link"), RDF.type, expected) in g + + +def test_dumper_string_derived_anyuri_is_a_typed_literal() -> None: + """Generated Python instances preserve relative URI literals in RDF.""" + module = compile_python(str(PythonGenerator(SCHEMA, mergeimports=True).serialize())) + resource = module.Resource( + id="ex:r1", + link="https://example.org/target", + homepage="https://example.org/home", + path="./data/file.png", + ) + g = rdflib_dumper.as_rdf_graph(resource, schemaview=SchemaView(SCHEMA)) + subject = URIRef(EX + "r1") + + assert g.value(subject, URIRef(EX + "path")) == Literal("./data/file.png", datatype=XSD.anyURI) + assert g.value(subject, URIRef(EX + "link")) == URIRef("https://example.org/target") + assert g.value(subject, URIRef(EX + "homepage")) == URIRef("https://example.org/home") + + +@pytest.mark.parametrize("datatype", ["xsd:anyURI", "xs:anyURI", str(XSD.anyURI)]) +@pytest.mark.parametrize("derived", [False, True]) +@pytest.mark.parametrize("path", ["./data/file.png", "#section", "https://example.net/file"]) +def test_rdf_representation_contract(datatype: str, derived: bool, path: str) -> None: + """Generated contexts and Python RDF agree, and both shape languages validate them.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["xs"] = str(XSD) + schema["types"]["FilePath"]["uri"] = datatype + schema["types"]["Homepage"]["uri"] = datatype + if derived: + for name in ("FilePath", "Homepage"): + schema["types"][name + "Child"] = {"typeof": name} + schema["slots"]["path"]["range"] = "FilePathChild" + schema["slots"]["homepage"]["range"] = "HomepageChild" + source = yaml.safe_dump(schema) + context = json.loads(ContextGenerator(source, xsd_anyuri_as_iri=True).serialize())["@context"] + document = { + "@context": context, + "@type": "Resource", + "id": "ex:r1", + "link": "https://example.org/target", + "homepage": "https://example.org/home", + "path": path, + } + graph = Graph().parse(data=json.dumps(document), format="json-ld", publicID="https://unrelated.example/base/") + subject = URIRef(EX + "r1") + predicate = URIRef(EX + "path") + assert graph.value(subject, predicate) == Literal(path, datatype=XSD.anyURI) + module = compile_python(str(PythonGenerator(source).serialize())) + instance = module.Resource(**{key: value for key, value in document.items() if not key.startswith("@")}) + dumped = rdflib_dumper.as_rdf_graph(instance, schemaview=SchemaView(source)) + assert isomorphic(graph, dumped) + + shacl = ShaclGenerator(source).as_graph() + shex = ShExGenerator(source).serialize() + assert validate(graph, shacl_graph=shacl, meta_shacl=True)[0] + results = ShExEvaluator(rdf=graph, schema=shex, focus=subject, start=EX + "Resource").evaluate() + assert results and all(result.result for result in results), [result.reason for result in results] + owl = OwlSchemaGenerator(source, xsd_anyuri_as_iri=True, type_objects=False).as_graph() + assert (predicate, RDF.type, OWL.DatatypeProperty) in owl + assert (URIRef(EX + "homepage"), RDF.type, OWL.ObjectProperty) in owl + + for wrong in (URIRef("https://example.org/file"), Literal(path)): + graph.set((subject, predicate, wrong)) + assert not validate(graph, shacl_graph=shacl)[0] + results = ShExEvaluator(rdf=graph, schema=shex, focus=subject, start=EX + "Resource").evaluate() + assert results and not any(result.result for result in results) + + +def test_context_uses_supplied_schema_without_reopening_source() -> None: + """A source_file annotation is provenance, not permission to replace the given schema.""" + schema = SchemaView(SCHEMA).schema + schema.source_file = "/not/a/real/schema.yaml" + context = json.loads(ContextGenerator(schema, xsd_anyuri_as_iri=True).serialize())["@context"] + assert context["path"]["@type"] == "xsd:anyURI" + assert context["homepage"]["@type"] == "@id" + + +def test_uri_ancestry_with_string_datatype_is_not_promoted() -> None: + """An explicit datatype override must agree across all generators.""" + schema = yaml.safe_load(SCHEMA) + schema["types"]["Homepage"]["uri"] = "xsd:string" + source = yaml.safe_dump(schema) + context = json.loads(ContextGenerator(source, xsd_anyuri_as_iri=True).serialize())["@context"] + assert context["homepage"].get("@type") != "@id" + owl = OwlSchemaGenerator(source, xsd_anyuri_as_iri=True, type_objects=False).as_graph() + assert (URIRef(EX + "homepage"), RDF.type, OWL.DatatypeProperty) in owl + shacl = ShaclGenerator(source).as_graph() + assert shacl.value(_property_shape(shacl, "homepage"), SH.datatype) == XSD.string + dumper = rdflib_dumper.inject_triples("https://example.org/value", SchemaView(source), Graph(), "Homepage") + assert dumper == Literal("https://example.org/value") + + +@pytest.mark.parametrize("as_node", [False, True]) +def test_jsonld_union_preserves_literal_and_explicit_node_values(as_node: bool) -> None: + """Both alternatives retain their RDF terms through JSON-LD expansion and SHACL.""" + context = json.loads(ContextGenerator(ANY_OF_SCHEMA, xsd_anyuri_as_iri=True).serialize())["@context"] + value = {"@id": EX + "target", "@type": "Target"} if as_node else "./data/file.png" + document = {"@context": context, "@id": EX + "holder", "@type": "Holder", "either_path": value} + graph = Graph().parse(data=json.dumps(document), format="json-ld", publicID="https://unrelated.example/base/") + expected = URIRef(EX + "target") if as_node else Literal(value, datatype=XSD.anyURI) + assert graph.value(URIRef(EX + "holder"), URIRef(EX + "eitherPath")) == expected + assert validate(graph, shacl_graph=ShaclGenerator(ANY_OF_SCHEMA).as_graph(), meta_shacl=True)[0] + graph.set((URIRef(EX + "holder"), URIRef(EX + "eitherPath"), Literal("wrong datatype"))) + assert not validate(graph, shacl_graph=ShaclGenerator(ANY_OF_SCHEMA).as_graph())[0] diff --git a/tests/linkml/test_generators/test_jsonldcontextgen.py b/tests/linkml/test_generators/test_jsonldcontextgen.py index 3e0ad405f6..2672bddcb4 100644 --- a/tests/linkml/test_generators/test_jsonldcontextgen.py +++ b/tests/linkml/test_generators/test_jsonldcontextgen.py @@ -1291,8 +1291,8 @@ def test_xsd_anyuri_as_iri_owl_curie_unchanged(): ``curie`` maps to ``xsd:string`` (not ``xsd:anyURI``), so the ``--xsd-anyuri-as-iri`` flag must not promote it to ObjectProperty. This verifies cross-generator consistency: the JSON-LD context generator - already correctly excludes ``curie`` via ``URI_RANGES_WITH_XSD``; the - OWL generator must match via ``is_xsd_anyuri_range()``. + already excludes ``curie``, and both generators decide by + ``is_xsd_anyuri_range()``. """ from rdflib import OWL, RDF, URIRef diff --git a/tests/linkml_runtime/test_loaders_dumpers/test_rdflib_dumper.py b/tests/linkml_runtime/test_loaders_dumpers/test_rdflib_dumper.py index 2df96c656a..c5ec8ebabc 100644 --- a/tests/linkml_runtime/test_loaders_dumpers/test_rdflib_dumper.py +++ b/tests/linkml_runtime/test_loaders_dumpers/test_rdflib_dumper.py @@ -9,7 +9,7 @@ from linkml_runtime import DataNotFoundError, MappingError from linkml_runtime.dumpers import rdflib_dumper, yaml_dumper -from linkml_runtime.linkml_model import Prefix +from linkml_runtime.linkml_model import Prefix, SchemaDefinition from linkml_runtime.loaders import rdflib_loader, yaml_loader from linkml_runtime.utils.schemaview import SchemaView from tests.linkml_runtime.test_loaders_dumpers import INPUT_DIR, OUTPUT_DIR @@ -33,6 +33,31 @@ logger = logging.getLogger(__name__) +@pytest.mark.parametrize("datatype", ["xsd:anyURI", "xs:anyURI", str(XSD.anyURI)]) +@pytest.mark.parametrize("parent, is_iri", [("uri", True), ("uriorcurie", True), ("string", False), ("curie", False)]) +def test_derived_uri_representation(datatype: str, parent: str, is_iri: bool) -> None: + """Type ancestry and expanded datatype jointly determine the serialized RDF term.""" + schema = SchemaView( + SchemaDefinition( + **{ + "id": "https://example.org/schema", + "name": "uri_representation", + "imports": ["linkml:types"], + "prefixes": {"linkml": "https://w3id.org/linkml/", "ex": "https://example.org/", "xs": str(XSD)}, + "types": {"Parent": {"typeof": parent, "uri": datatype}, "Child": {"typeof": "Parent"}}, + } + ) + ) + value = "ex:resource" + term = rdflib_dumper.inject_triples(value, schema, Graph(), "Child") + expected = URIRef("https://example.org/resource") if is_iri else Literal(value, datatype=XSD.anyURI) + assert term == expected + graph = Graph() + graph.add((URIRef("https://example.org/s"), URIRef("https://example.org/p"), term)) + reparsed = Graph().parse(data=graph.serialize(format="turtle"), format="turtle") + assert reparsed.value(URIRef("https://example.org/s"), URIRef("https://example.org/p")) == expected + + INPUT_PATH = Path(INPUT_DIR) OUTPUT_PATH = Path(OUTPUT_DIR) From d698ad86e92f88bfdc9235f05b32c265e5fccdb8 Mon Sep 17 00:00:00 2001 From: jdsika Date: Mon, 5 Oct 2026 11:12:35 +0200 Subject: [PATCH 27/30] fix(generators): enforce range_expression on each value alike in SHACL and JSON Schema gen-shacl ignored a slot's range_expression, and gen-json-schema read it with the two-valued builder of rules, which ignores cardinalities, the range of a condition, and the slot_usage and aliases of the value class. Both generators now read it as a class-level expression: each value must satisfy it as an instance of the range class satisfies a class-level expression. In SHACL it is an sh:node next to the range's sh:class. On a class alternative of any_of it applies to that alternative, and in a slot condition it takes the form of the condition. SHACL skips an expression it cannot express with a warning, as it skips a class-level expression. gen-json-schema applied the value constraints of a slot inlined as a dict, and the conditions of class expressions on it, to the dict instead of to each value. Signed-off-by: jdsika --- docs/generators/json-schema.rst | 3 + docs/generators/shacl.rst | 71 ++- docs/schemas/advanced.md | 2 + .../src/linkml/generators/jsonschemagen.py | 86 +++- .../linkml/src/linkml/generators/shaclgen.py | 149 ++++-- .../test_boolean_slot_compliance.py | 2 +- .../test_generators/test_range_expression.py | 479 ++++++++++++++++++ tests/linkml/test_generators/test_shaclgen.py | 2 +- 8 files changed, 725 insertions(+), 69 deletions(-) create mode 100644 tests/linkml/test_generators/test_range_expression.py diff --git a/docs/generators/json-schema.rst b/docs/generators/json-schema.rst index 9515b76dfc..14f9501e8d 100644 --- a/docs/generators/json-schema.rst +++ b/docs/generators/json-schema.rst @@ -135,6 +135,9 @@ A slot condition is unknown for an absent slot unless it decides whether the slo may be absent, and an instance is invalid only when an expression is definitely false, as described under "Class-level expressions and absent slots" in :doc:`Advanced features `. +A slot's `range_expression `_ +constrains each of its values in the same way, with its conditions on the slots +of the value. The SHACL generator reads them the same way. Inlining diff --git a/docs/generators/shacl.rst b/docs/generators/shacl.rst index 05e77d5f18..150d5b46c1 100644 --- a/docs/generators/shacl.rst +++ b/docs/generators/shacl.rst @@ -197,8 +197,9 @@ LinkML SHACL, on the class's ``sh:NodeShape`` ``none_of`` one ``sh:not`` per member ================== ===================================================== -Each member becomes an anonymous node shape. ``is_a`` gives ``sh:class``, and -nested expressions recurse. Each entry of ``slot_conditions`` gives an +Each member becomes an anonymous node shape. ``is_a`` gives ``sh:class`` for a +class and the value constraint of a type or an enum otherwise, and nested +expressions recurse. Each entry of ``slot_conditions`` gives an ``sh:property`` whose path is that of the slot as induced for the class, so ``slot_usage`` applies: @@ -211,7 +212,9 @@ nested expressions recurse. Each entry of ``slot_conditions`` gives an * ``equals_string`` and ``equals_string_in`` give ``sh:in``; on an enum slot the values are the permissible values as the enum renders them, the IRI of their ``meaning`` where they have one; -* ``range`` gives the same class, type or enum constraint as a slot's range. +* ``range`` gives the same class, type or enum constraint as a slot's range; +* ``range_expression`` gives an ``sh:node``, as described under + `Range Expressions`_. A shape may have at most one value of ``sh:minInclusive``, ``sh:maxInclusive`` or ``sh:in``, and of ``sh:pattern``, whose component also takes ``sh:flags`` @@ -261,12 +264,70 @@ slot's range. An operator whose members use anything else is skipped as a whole and logged as a warning, because leaving out one member would change what the operator -admits. That covers, for example, ``has_member`` or a slot-level ``any_of`` -inside a slot condition, a condition on a name that is not a slot, a condition +admits. That covers, for example, ``has_member``, ``equals_expression`` or a +slot-level ``any_of`` inside a slot condition, a condition on a name that is not a slot, a condition on the identifier slot (the node's IRI rather than a property), and ``equals_string`` on a slot whose range does not hold strings. +Range Expressions +^^^^^^^^^^^^^^^^^ + +A slot's `range_expression `__ +constrains each of its values as a class-level expression constrains an +instance: its conditions constrain the slots of the value as induced for the +range class, and a value violates it only when it is definitely false. It +becomes an ``sh:node`` (`SHACL §4.7.1 +`__) on the property shape, +next to the range's ``sh:class``, so it constrains only the values of this slot, +and matching it doesn't make a node an instance of the range. The JSON Schema +generator reads it the same way. + +.. code-block:: yaml + + classes: + Document: + slots: [license] + slot_usage: + license: + range_expression: + slot_conditions: + category: + required: true + equals_string: license + Resource: + slots: [category] + slots: + license: + range: Resource + category: + range: string + +.. code-block:: turtle + + ex:Document a sh:NodeShape ; + sh:property [ sh:path ex:license ; + sh:class ex:Resource ; + sh:node [ sh:property [ sh:path ex:category ; + sh:minCount 1 ; + sh:in ( "license" ) ] ] ; + ... ] ; + ... + +A class alternative in a slot's ``any_of`` can carry its own +``range_expression``, and so can a slot condition, where the expression takes +the form of the condition: under ``sh:not``, its "definitely true" form. For +values that aren't instances of a class, an expression can combine types and +enums with ``is_a``, for example ``any_of: [{is_a: OpenLicense}, {is_a: +ProprietaryLicense}]``; such values have no slots for a condition to constrain. +An expression that uses anything SHACL can't express (see above) is skipped and +logged as a warning. + +A value that is a reference is checked against its description in the data +graph, as ``sh:class`` is. The JSON Schema generator can't check it, because +there the value is just an identifier. + + Command Line ^^^^^^^^^^^^ diff --git a/docs/schemas/advanced.md b/docs/schemas/advanced.md index 9911e158e5..297575ed60 100644 --- a/docs/schemas/advanced.md +++ b/docs/schemas/advanced.md @@ -92,6 +92,8 @@ A `Sample` without `status` is valid: the condition is unknown, so `none_of` isn | `none_of: [label = A]` | valid | invalid | valid | | `exactly_one_of: [label = A, note = B]` | invalid | valid | invalid | +A slot's [range_expression](https://w3id.org/linkml/range_expression) constrains each value of the slot in the same way: its conditions constrain the slots of the value, as induced for the slot's range class. In a slot condition, a `range_expression` is part of the condition, so under `none_of` it must hold definitely. + [Rules](#rules) are read differently: their preconditions require their slots, and so do their postconditions unless the rule is `open_world`. ### Unions as ranges diff --git a/packages/linkml/src/linkml/generators/jsonschemagen.py b/packages/linkml/src/linkml/generators/jsonschemagen.py index b3fdb2ed49..6b1452e9a7 100644 --- a/packages/linkml/src/linkml/generators/jsonschemagen.py +++ b/packages/linkml/src/linkml/generators/jsonschemagen.py @@ -2,7 +2,7 @@ import json import logging import os -from copy import deepcopy +from copy import copy, deepcopy from dataclasses import dataclass, field from typing import Any, cast @@ -655,7 +655,7 @@ def handle_class(self, cls: ClassDefinition) -> None: _CLASS_OPERATOR_KEYWORDS = {"any_of": "anyOf", "all_of": "allOf", "exactly_one_of": "oneOf"} def get_subschema_for_class_operator( - self, cls: ClassDefinition, operator: str, members: list[AnonymousClassExpression], definite: bool + self, cls: ClassDefinition | None, operator: str, members: list[AnonymousClassExpression], definite: bool ) -> JsonSchema: """The subschema of the class-level boolean expression *operator* over *members*, for *cls*. @@ -681,16 +681,18 @@ def get_subschema_for_class_operator( ) def get_subschema_for_class_expression( - self, cls: ClassDefinition, expr: AnonymousClassExpression, definite: bool + self, cls: ClassDefinition | None, expr: AnonymousClassExpression, definite: bool ) -> JsonSchema: """The subschema of one member *expr* of a class-level boolean expression of *cls*. Each slot condition constrains the slot as induced for *cls*, so - ``slot_usage`` applies. Its values must satisfy its value operators - and its ``range``; the number of values must lie within + ``slot_usage`` applies. Its values must satisfy its value operators, + its ``range`` and its ``range_expression``, the latter in the form of + *expr*; the number of values must lie within :func:`~linkml.generators.common.class_expression.value_bounds`, where an empty list counts as no value. *definite* selects the form, as in - :meth:`get_subschema_for_class_operator`. + :meth:`get_subschema_for_class_operator`. *cls* is ``None`` for values + that are not instances of a class. """ subschema = JsonSchema() conjuncts: list[JsonSchema] = [] @@ -700,23 +702,45 @@ def get_subschema_for_class_expression( prop_name = self._curie(slot) else: prop_name = self.aliased_slot_name(slot) - values = self.get_subschema_for_slot(condition, omit_type=True, include_null=False) + value_condition = condition + if condition.range_expression is not None: + # translated below, in the form of *expr* + value_condition = copy(condition) + value_condition.range_expression = None + values = self.get_subschema_for_slot(value_condition, omit_type=True, include_null=False) + if condition.range_expression is not None: + expression = self.get_subschema_for_range_expression( + condition.range or slot.range, condition.range_expression, definite + ) + values = JsonSchema({"allOf": [values, expression]}) if values else expression + lower, upper = value_bounds(condition, definite) + if slot.multivalued and self._inlined_as_dict(slot): + prop = JsonSchema({"type": "object", "additionalProperties": values}) + prop.add_keyword("minProperties", lower or None) + prop.add_keyword("maxProperties", upper) + elif slot.multivalued: + prop = JsonSchema.array_of(values, include_null=False, required=False) + prop.add_keyword("minItems", lower or None) + prop.add_keyword("maxItems", upper) + else: + prop = values if condition.range is not None: + # the values in the representation of the slot, as a list, a dict or a single value typed = self.get_subschema_for_slot( SlotDefinition( - slot.name, range=condition.range, inlined=slot.inlined, inlined_as_list=slot.inlined_as_list + slot.name, + range=condition.range, + multivalued=slot.multivalued, + inlined=slot.inlined, + inlined_as_list=slot.inlined_as_list, ), include_null=False, ) - values = JsonSchema({"allOf": [values, typed]}) if values else typed - lower, upper = value_bounds(condition, definite) + prop = JsonSchema({"allOf": [prop, typed]}) if prop else typed if slot.multivalued: - prop = JsonSchema.array_of(values, include_null=False, required=False) - prop.add_keyword("minItems", lower or None) - prop.add_keyword("maxItems", upper) subschema.add_property(prop_name, prop, value_required=lower > 0) else: - subschema.add_property(prop_name, values, value_required=lower > 0, value_disallowed=upper == 0) + subschema.add_property(prop_name, prop, value_required=lower > 0, value_disallowed=upper == 0) if lower > 1: # a single-valued slot holds at most one value conjuncts.append(JsonSchema({"not": {}})) @@ -731,13 +755,35 @@ def get_subschema_for_class_expression( subschema.setdefault("allOf", []).extend(conjuncts) return subschema - def _class_expression_slot(self, cls: ClassDefinition, slot_name: str) -> SlotDefinition | None: + def _inlined_as_dict(self, slot: SlotDefinition) -> bool: + """Whether the values of multivalued *slot* are inlined as a dict, keyed by their identifier.""" + return ( + self.schemaview.is_inlined(slot) + and not slot.inlined_as_list + and get_range_associated_slots(self.schemaview, slot.range)[0] is not None + ) + + def _class_expression_slot(self, cls: ClassDefinition | None, slot_name: str) -> SlotDefinition | None: """The slot a condition of a class expression of *cls* names, as induced for *cls*, if any.""" sv = self.schemaview - if slot_name in sv.class_slots(cls.name): + if cls is not None and slot_name in sv.class_slots(cls.name): return sv.induced_slot(slot_name, cls.name) return sv.get_slot(slot_name) + def get_subschema_for_range_expression( + self, value_range: str | None, expression: AnonymousClassExpression, definite: bool + ) -> JsonSchema: + """The subschema a value of range *value_range* must satisfy under *expression*. + + A ``range_expression`` constrains each value as a class-level expression + constrains an instance (:meth:`get_subschema_for_class_expression`): its + conditions constrain the slots of the value as induced for the class + *value_range*, and *definite* selects the form. + """ + sv = self.schemaview + value_class = sv.get_class(value_range) if value_range in sv.all_classes() else None + return self.get_subschema_for_class_expression(value_class, expression, definite) + def get_subschema_for_anonymous_class( self, cls: AnonymousClassExpression, properties_required: bool = False ) -> None | JsonSchema: @@ -904,7 +950,7 @@ def get_value_constraints_for_slot(self, slot: SlotDefinition | AnonymousSlotExp constraints.add_keyword("enum", slot.equals_string_in) if slot.range_expression: - subschema = self.get_subschema_for_anonymous_class(slot.range_expression) + subschema = self.get_subschema_for_range_expression(slot.range, slot.range_expression, definite=False) if subschema: if "allOf" not in constraints: constraints["allOf"] = [] @@ -970,6 +1016,7 @@ def get_subschema_for_slot( slot_is_boolean = any([slot.any_of, slot.all_of, slot.exactly_one_of, slot.none_of]) typ = None + inlined_as_dict = False if not omit_type: typ, fmt, reference = self.get_type_info_for_slot_subschema(slot) if slot_is_inlined: @@ -983,6 +1030,7 @@ def get_subschema_for_slot( # if the range class has an ID and the slot is not inlined as a list, then we need to consider # various inlined as dict formats if range_id_slot is not None and not slot.inlined_as_list: + inlined_as_dict = True # At a minimum, the inlined dict can have keys (additionalProps) that are IDs # and the values are the range class but possibly omitting the ID. additionalProps = [JsonSchema.ref_for(reference, identifier_optional=True)] @@ -1063,6 +1111,10 @@ def get_subschema_for_slot( prop["items"].update(all_element_constraints) if any_element_constraints: prop["contains"] = any_element_constraints + elif inlined_as_dict: + # The values of the slot are the values of the dict. + if own_constraints: + prop["additionalProperties"] = JsonSchema({"allOf": [prop["additionalProperties"], own_constraints]}) else: prop.update(own_constraints) diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index bb8019b3ee..9f2f1ca12f 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -271,6 +271,7 @@ def as_graph(self) -> Graph: self._class_expressions_added: set[tuple[URIRef, str, str]] = set() self._class_expression_problems: dict[tuple[str, str, str], list[str]] = {} + self._range_expression_problems: dict[tuple[str, str], list[str]] = {} for c in sv.all_classes(imports=not self.exclude_imports).values(): def shape_pv(p, v): @@ -381,47 +382,25 @@ def prop_pv_text(p, v): range_list = [] for any in s.any_of: r = any.range - if r in all_classes: - class_node = BNode() + branch = BNode() - def cl_node_pv(p, v): - if v is not None: - g.add((class_node, p, v)) + def branch_pv(p, v): + if v is not None: + g.add((branch, p, v)) - self._add_class(cl_node_pv, r) - range_list.append(class_node) + if r in all_classes: + self._add_class(branch_pv, r) elif r in sv.all_types(): - t_node = BNode() - - def t_node_pv(p, v): - if v is not None: - g.add((t_node, p, v)) - - self._add_type(t_node_pv, r) - range_list.append(t_node) + self._add_type(branch_pv, r) elif r in sv.all_enums(): - en_node = BNode() - - def en_node_pv(p, v): - if v is not None: - g.add((en_node, p, v)) - - self._add_enum(g, en_node_pv, r) - range_list.append(en_node) + self._add_enum(g, branch_pv, r) else: - st_node = BNode() - - def st_node_pv(p, v): - if v is not None: - g.add((st_node, p, v)) - - add_simple_data_type(st_node_pv, r) - range_list.append(st_node) - # Propagate pattern constraint to the branch node. - # A branch may combine range + pattern (e.g. range: string - # with pattern: "^...") or specify pattern alone (no range). + add_simple_data_type(branch_pv, r) + if any.range_expression is not None: + self._add_range_expression(g, branch_pv, c, s, r, any.range_expression) if any.pattern: - g.add((range_list[-1], SH.pattern, Literal(any.pattern))) + g.add((branch, SH.pattern, Literal(any.pattern))) + range_list.append(branch) Collection(g, or_node, range_list) else: prop_pv_literal(SH.hasValue, s.equals_number) @@ -447,6 +426,9 @@ def st_node_pv(p, v): # Map subproperty_of to sh:in with slot descendants self._add_subproperty_constraint(g, prop_pv, s) + if s.range_expression is not None: + self._add_range_expression(g, prop_pv, c, s, s.range, s.range_expression) + if s.annotations and self.include_annotations: self._add_annotations(prop_pv, s) @@ -461,6 +443,7 @@ def st_node_pv(p, v): self._report_rule_problems() self._report_class_expression_problems() + self._report_range_expression_problems() return g LINKML_ANY_URI = "https://w3id.org/linkml/Any" @@ -484,7 +467,16 @@ def st_node_pv(p, v): {"required", "value_presence", "minimum_cardinality", "maximum_cardinality", "exact_cardinality"} ) _SLOT_CONDITION_VALUE_FIELDS = frozenset( - {"minimum_value", "maximum_value", "pattern", "equals_string", "equals_string_in", "equals_number", "range"} + { + "minimum_value", + "maximum_value", + "pattern", + "equals_string", + "equals_string_in", + "equals_number", + "range", + "range_expression", + } ) _SLOT_CONDITION_FIELDS = _SLOT_CONDITION_PRESENCE_FIELDS | _SLOT_CONDITION_VALUE_FIELDS @@ -549,7 +541,8 @@ def _add_class_expressions(self, g: Graph, shape_uri: URIRef, cls: ClassDefiniti are separate constraints that all apply (SHACL §2.1.1), so the node must conform to none of the members. - Every member becomes an anonymous node shape: ``is_a`` gives ``sh:class``, + Every member becomes an anonymous node shape: ``is_a`` gives ``sh:class`` + for a class and the value constraint of a type or an enum otherwise, each slot condition gives an ``sh:property`` on the path of the slot as induced for *cls* (so ``slot_usage`` applies), and nested expressions recurse. @@ -603,7 +596,7 @@ def _add_logical_constraint( self, g: Graph, subject: URIRef | BNode, - cls: ClassDefinition, + cls: ClassDefinition | None, operator: str, members: list[AnonymousClassExpression], definite: bool, @@ -629,7 +622,7 @@ def _add_logical_constraint( g.add((subject, predicate, list_node)) def _class_expression_shape( - self, g: Graph, cls: ClassDefinition, expr: AnonymousClassExpression, definite: bool + self, g: Graph, cls: ClassDefinition | None, expr: AnonymousClassExpression, definite: bool ) -> BNode: """Build the anonymous node shape for one class expression *expr*, in its "definitely true" form with *definite*, otherwise its "not false" form.""" @@ -644,7 +637,10 @@ def node_pv(p, v): if expr.description is not None: node_pv(RDFS.comment, Literal(expr.description, lang=self._resolve_language(expr))) if expr.is_a is not None: - self._add_class(node_pv, expr.is_a) + if expr.is_a in self.schemaview.all_classes(): + self._add_class(node_pv, expr.is_a) + else: + self._add_range(g, node_pv, expr.is_a) for slot_name, condition in expr.slot_conditions.items(): node_pv(SH.property, self._slot_condition_shape(g, cls, slot_name, condition, definite)) for operator in self._CLASS_EXPRESSION_OPERATORS: @@ -705,6 +701,8 @@ def prop_pv(p, v): prop_pv(SH.maxInclusive, Literal(condition.equals_number)) if condition.range is not None: self._add_range(g, prop_pv, condition.range) + if condition.range_expression is not None: + prop_pv(SH.node, self._range_expression_shape(g, value_range, condition.range_expression, definite)) if repeated: # Each repeated parameter in a member shape of its own: all of them hold # for every value, as they would on the property shape itself. @@ -769,18 +767,23 @@ def _is_string_range(self, r: ElementName | None) -> bool: return True return self._type_uri(r) == str(XSD.string) - def _untranslatable(self, cls: ClassDefinition, expr: AnonymousClassExpression) -> str | None: - """Return what in class expression *expr* of *cls* cannot be translated, or ``None``.""" - sv = self.schemaview + def _untranslatable(self, cls: ClassDefinition | None, expr: AnonymousClassExpression) -> str | None: + """Return what in class expression *expr* of *cls* cannot be translated, or ``None``. + + *cls* is ``None`` when *expr* constrains values that are not instances of + a class, which have no slots for a condition to constrain. + """ unknown = self._set_operator_fields(expr) - self._CLASS_EXPRESSION_FIELDS if unknown: return f"'{sorted(unknown)[0]}'" - if expr.is_a is not None and expr.is_a not in sv.all_classes(): - return f"is_a '{expr.is_a}', which is not a class" + if expr.is_a is not None and not self._is_known_range(expr.is_a): + return f"is_a '{expr.is_a}', which is not a known class, type or enum" for slot_name, condition in expr.slot_conditions.items(): unknown = self._set_operator_fields(condition) - self._SLOT_CONDITION_FIELDS if unknown: return f"'{sorted(unknown)[0]}' in the condition on slot '{slot_name}'" + if cls is None: + return f"a condition on slot '{slot_name}' of values that are not class instances" slot = self._condition_slot(cls, slot_name) if slot is None: return f"a condition on '{slot_name}', which is not a slot" @@ -790,6 +793,10 @@ def _untranslatable(self, cls: ClassDefinition, expr: AnonymousClassExpression) if condition.range is not None and not self._is_known_range(condition.range): return f"the unknown range '{condition.range}' in the condition on slot '{slot_name}'" value_range = condition.range or slot.range + if condition.range_expression is not None: + reason = self._untranslatable(self._value_class(value_range), condition.range_expression) + if reason is not None: + return reason if (condition.equals_string is not None or condition.equals_string_in) and not self._is_string_range( value_range ): @@ -801,6 +808,58 @@ def _untranslatable(self, cls: ClassDefinition, expr: AnonymousClassExpression) return reason return None + def _value_class(self, value_range: ElementName | None) -> ClassDefinition | None: + """The class whose instances a range *value_range* holds, or ``None`` for any other range.""" + sv = self.schemaview + return sv.get_class(value_range) if value_range in sv.all_classes() else None + + def _range_expression_shape( + self, g: Graph, value_range: ElementName | None, expression: AnonymousClassExpression, definite: bool + ) -> BNode: + """Build the node shape a value of range *value_range* must conform to under *expression*. + + A ``range_expression`` constrains each value as a class-level expression + constrains an instance: its conditions constrain the slots of the value + as induced for the class *value_range*, and *definite* selects the form, + as in :meth:`_class_expression_shape`. ``sh:node`` (SHACL §4.7.1) + applies the shape to each value alongside the range's own constraint, + and only to the values of this slot. + """ + return self._class_expression_shape(g, self._value_class(value_range), expression, definite) + + def _add_range_expression( + self, + g: Graph, + prop_pv: Callable, + cls: ClassDefinition, + slot: SlotDefinition, + value_range: ElementName | None, + expression: AnonymousClassExpression, + ) -> None: + """Constrain each value of range *value_range* of *slot*, in the shape of *cls*, by *expression*. + + The expression is read in its "not false" form. One that cannot be + translated is skipped with a warning, as a class-level expression is. + """ + reason = self._untranslatable(self._value_class(value_range), expression) + if reason is not None: + classes = self._range_expression_problems.setdefault((slot.name, reason), []) + if cls.name not in classes: + classes.append(cls.name) + return + prop_pv(SH.node, self._range_expression_shape(g, value_range, expression, definite=False)) + + def _report_range_expression_problems(self) -> None: + """Warn once about each ``range_expression`` of a slot that is not translated, + naming the class shapes it is missing from.""" + for (slot_name, reason), classes in self._range_expression_problems.items(): + logger.warning( + "Slot %r: range_expression is not translated to SHACL, because it uses %s (in the shapes of %s).", + slot_name, + reason, + ", ".join(map(repr, classes)), + ) + def _is_known_range(self, r: ElementName) -> bool: """Whether *r* names a class, type or enum of the schema, or a built-in type.""" sv = self.schemaview diff --git a/tests/linkml/test_compliance/test_boolean_slot_compliance.py b/tests/linkml/test_compliance/test_boolean_slot_compliance.py index e73ff46851..8167f7ae95 100644 --- a/tests/linkml/test_compliance/test_boolean_slot_compliance.py +++ b/tests/linkml/test_compliance/test_boolean_slot_compliance.py @@ -2903,7 +2903,7 @@ def test_range_expression_nesting(framework, data_name, instance, is_valid): core_elements=["range_expression"], ) expected_behavior = ValidationBehavior.IMPLEMENTS - if framework not in [JSON_SCHEMA, OWL]: + if framework not in [JSON_SCHEMA, OWL, SHACL]: if not is_valid: expected_behavior = ValidationBehavior.INCOMPLETE check_data( diff --git a/tests/linkml/test_generators/test_range_expression.py b/tests/linkml/test_generators/test_range_expression.py new file mode 100644 index 0000000000..755626e980 --- /dev/null +++ b/tests/linkml/test_generators/test_range_expression.py @@ -0,0 +1,479 @@ +"""A slot's ``range_expression`` constrains each of its values alike in the JSON Schema and SHACL generators. + +Each value must satisfy the expression as an instance of its range class +satisfies a class-level expression: its conditions constrain the slots of the +value as induced for that class, and an expression is violated only when it is +definitely false. The agreement tests validate the same instance with both +generated artifacts, loading it as RDF through the generated JSON-LD context. +""" + +import json +import logging +from pathlib import Path +from typing import Any + +import pytest +import yaml +from click.testing import CliRunner +from jsonschema import Draft201909Validator +from pyshacl import validate +from rdflib import Graph, Namespace, URIRef +from rdflib.namespace import SH + +from linkml.generators.jsonldcontextgen import ContextGenerator +from linkml.generators.jsonschemagen import JsonSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator, cli +from linkml_runtime import SchemaView + +EX = Namespace("https://example.org/context/") +PREFIX = f"@prefix ex: <{EX}> . " + + +@pytest.fixture +def schema() -> dict[str, Any]: + """A resource is constrained differently when used as a document's license.""" + return yaml.safe_load(""" +id: https://example.org/context +name: context +prefixes: + ex: https://example.org/context/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +imports: [linkml:types] +classes: + Document: + tree_root: true + slots: [license] + slot_usage: + license: + range_expression: + slot_conditions: + category: {equals_string: license, required: true} + Resource: + slots: [category, code, tags, details] + Details: + slots: [code] +slots: + license: {range: Resource, inlined: true} + category: {range: string} + code: {range: integer} + tags: {range: string, multivalued: true} + details: {range: Details, inlined: true} +enums: + Category: + permissible_values: + license: {} +""") + + +def _as_json_ld(sv: SchemaView, obj: dict[str, Any], class_name: str) -> dict[str, Any]: + """*obj* with an ``@type`` on it and on each inlined object: the range of its slot, unless it states one.""" + class_name = obj.get("@type", class_name) + typed = {**obj, "@type": class_name} + for slot in sv.class_induced_slots(class_name): + value = obj.get(slot.name) + if slot.range in sv.all_classes() and value is not None: + values = [_as_json_ld(sv, v, slot.range) for v in (value if isinstance(value, list) else [value])] + typed[slot.name] = values if isinstance(value, list) else values[0] + return typed + + +def _without_types(obj: Any) -> Any: + """*obj* without its ``@type`` keys.""" + if isinstance(obj, dict): + return {k: _without_types(v) for k, v in obj.items() if k != "@type"} + if isinstance(obj, list): + return [_without_types(v) for v in obj] + return obj + + +def verdicts(schema: dict[str, Any], instance: dict[str, Any], target: str = "Document") -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL shapes generated for *schema* accept *instance* of *target*.""" + text = yaml.safe_dump(schema) + json_schema = json.loads(JsonSchemaGenerator(text, top_class=target).serialize()) + context = json.loads(ContextGenerator(text).serialize())["@context"] + data = {"@context": context, **_as_json_ld(SchemaView(text), instance, target)} + shapes = ShaclGenerator(text, closed=False).serialize() + conforms, _, _ = validate( + Graph().parse(data=json.dumps(data), format="json-ld"), + shacl_graph=Graph().parse(data=shapes, format="turtle"), + meta_shacl=True, + ) + return Draft201909Validator(json_schema).is_valid(_without_types(instance)), conforms + + +def conforms(schema: dict[str, Any], data: str, **options: Any) -> bool: + """Whether Turtle *data* conforms to the SHACL shapes generated for *schema*, which must pass meta-SHACL.""" + shapes = ShaclGenerator(yaml.safe_dump(schema), closed=False, **options).serialize() + return validate( + data_graph=PREFIX + data, + data_graph_format="turtle", + shacl_graph=shapes, + shacl_graph_format="turtle", + meta_shacl=True, + )[0] + + +def _equals(slot: str, value: str | int) -> dict[str, Any]: + """A class expression requiring *slot* to equal *value*, unknown when *slot* is absent.""" + key = "equals_number" if isinstance(value, int) else "equals_string" + return {"slot_conditions": {slot: {key: value}}} + + +_REQUIRED_LICENSE = {"slot_conditions": {"category": {"equals_string": "license", "required": True}}} +_LICENSE_OR_CODE = [_equals("category", "license"), _equals("code", 5)] +_NO_TAGS_AND_LICENSE = { + "slot_conditions": {"tags": {"maximum_cardinality": 0}}, + "all_of": [_equals("category", "license")], +} +_TWO_TAGS = {"slot_conditions": {"tags": {"minimum_cardinality": 2}}} +_CATEGORY_RANGE = {"slot_conditions": {"category": {"range": "Category"}}} +_CODE_FROM_TWO = {"slot_conditions": {"code": {"minimum_value": 2}}} +_REQUIRED_DETAILS = {"slot_conditions": {"details": {"required": True, "range_expression": _CODE_FROM_TWO}}} +_NO_DETAILS_CODE = {"none_of": [{"slot_conditions": {"details": {"range_expression": _CODE_FROM_TWO}}}]} + + +@pytest.mark.parametrize( + "expression,value,valid", + [ + pytest.param(_REQUIRED_LICENSE, {"category": "license"}, True, id="required-match"), + pytest.param(_REQUIRED_LICENSE, {"category": "other"}, False, id="required-other"), + pytest.param(_REQUIRED_LICENSE, {}, False, id="required-absent"), + # a condition that doesn't state presence is unknown for an absent slot + pytest.param(_equals("category", "license"), {}, True, id="optional-absent"), + pytest.param(_equals("category", "license"), {"category": "other"}, False, id="optional-other"), + pytest.param({"none_of": [_equals("category", "license")]}, {}, True, id="none_of-absent"), + pytest.param({"none_of": [_equals("category", "license")]}, {"category": "license"}, False, id="none_of-match"), + pytest.param({"none_of": [_equals("category", "license")]}, {"category": "other"}, True, id="none_of-other"), + pytest.param({"any_of": _LICENSE_OR_CODE}, {}, True, id="any_of-absent"), + pytest.param({"any_of": _LICENSE_OR_CODE}, {"category": "other"}, True, id="any_of-false-or-unknown"), + pytest.param({"any_of": _LICENSE_OR_CODE}, {"category": "other", "code": 1}, False, id="any_of-false"), + # exactly_one_of counts the members that are definitely true + pytest.param({"exactly_one_of": _LICENSE_OR_CODE}, {}, False, id="exactly_one_of-absent"), + pytest.param({"exactly_one_of": _LICENSE_OR_CODE}, {"category": "license"}, True, id="exactly_one_of-first"), + pytest.param({"exactly_one_of": _LICENSE_OR_CODE}, {"code": 5}, True, id="exactly_one_of-second"), + pytest.param( + {"exactly_one_of": _LICENSE_OR_CODE}, {"category": "license", "code": 5}, False, id="exactly_one_of-both" + ), + pytest.param({"exactly_one_of": _LICENSE_OR_CODE}, {"category": "other"}, False, id="exactly_one_of-none"), + pytest.param(_NO_TAGS_AND_LICENSE, {"tags": ["a"]}, False, id="absent-and-all_of-tags"), + pytest.param(_NO_TAGS_AND_LICENSE, {"category": "other"}, False, id="absent-and-all_of-other"), + pytest.param(_NO_TAGS_AND_LICENSE, {"category": "license"}, True, id="absent-and-all_of-match"), + pytest.param(_TWO_TAGS, {}, False, id="cardinality-absent"), + pytest.param(_TWO_TAGS, {"tags": ["a"]}, False, id="cardinality-too-few"), + pytest.param(_TWO_TAGS, {"tags": ["a", "b"]}, True, id="cardinality-enough"), + pytest.param(_CATEGORY_RANGE, {"category": "license"}, True, id="condition-range"), + pytest.param(_CATEGORY_RANGE, {"category": "other"}, False, id="condition-range-other"), + # a nested expression constrains the slots of the value's value, as induced for its range class + pytest.param(_REQUIRED_DETAILS, {"details": {"code": 2}}, True, id="nested"), + pytest.param(_REQUIRED_DETAILS, {"details": {"code": 1}}, False, id="nested-other"), + pytest.param(_REQUIRED_DETAILS, {"details": {}}, True, id="nested-absent"), + pytest.param(_REQUIRED_DETAILS, {}, False, id="nested-required"), + # under a negation, a nested expression takes its "definitely true" form + pytest.param(_NO_DETAILS_CODE, {"details": {}}, True, id="nested-in-none_of-absent"), + pytest.param(_NO_DETAILS_CODE, {"details": {"code": 3}}, False, id="nested-in-none_of-match"), + pytest.param(_NO_DETAILS_CODE, {"details": {"code": 1}}, True, id="nested-in-none_of-other"), + ], +) +@pytest.mark.parametrize("multivalued", [False, True]) +def test_range_expression_constrains_each_value( + schema: dict[str, Any], expression: dict[str, Any], value: dict[str, Any], valid: bool, multivalued: bool +) -> None: + """Both generators read a range expression alike, on a single value and on a list of values.""" + schema["classes"]["Document"]["slot_usage"]["license"]["range_expression"] = expression + schema["slots"]["license"]["multivalued"] = multivalued + assert verdicts(schema, {"license": [value] if multivalued else value}) == (valid, valid) + + +@pytest.mark.parametrize( + "values,valid", + [ + ([{"category": "license"}, {"category": "license"}], True), + ([{"category": "license"}, {"category": "other"}], False), + ([{"category": "license"}, {}], False), + ], +) +def test_range_expression_holds_for_every_value(schema: dict[str, Any], values: list[dict], valid: bool) -> None: + """Every value of a multivalued slot must satisfy the expression.""" + schema["slots"]["license"]["multivalued"] = True + assert verdicts(schema, {"license": values}) == (valid, valid) + + +@pytest.mark.parametrize( + "operator,value,valid", + [ + ("all_of", {}, True), + ("all_of", {"category": "license"}, True), + ("all_of", {"category": "other"}, False), + ("none_of", {}, True), + ("none_of", {"category": "license"}, False), + ("none_of", {"category": "other"}, True), + ], +) +def test_range_expression_in_class_expression( + schema: dict[str, Any], operator: str, value: dict[str, Any], valid: bool +) -> None: + """In a condition of a class-level expression, a range expression takes the form of the condition.""" + del schema["classes"]["Document"]["slot_usage"] + schema["classes"]["Document"][operator] = [ + {"slot_conditions": {"license": {"range_expression": _equals("category", "license")}}} + ] + assert verdicts(schema, {"license": value}) == (valid, valid) + + +@pytest.mark.parametrize("value,valid", [({"category": "license"}, True), ({"category": "other"}, False)]) +def test_range_expression_on_union_branch(schema: dict[str, Any], value: dict[str, Any], valid: bool) -> None: + """A class alternative of a slot's ``any_of`` carries its own range expression.""" + expression = schema["classes"]["Document"].pop("slot_usage")["license"]["range_expression"] + schema["classes"]["Note"] = {"slots": ["text"]} + schema["slots"]["text"] = {"range": "string", "required": True} + schema["slots"]["license"] = { + "inlined": True, + "any_of": [{"range": "Resource", "range_expression": expression}, {"range": "Note"}], + } + # the RDF type of a value of a union is the alternative it is an instance of + assert verdicts(schema, {"license": {"@type": "Resource", **value}}) == (valid, valid) + assert verdicts(schema, {"license": {"@type": "Note", "text": "explanation"}}) == (True, True) + + +def test_union_branch_keeps_its_class(schema: dict[str, Any]) -> None: + """Satisfying the expression of a class alternative doesn't make a value an instance of that class.""" + expression = schema["classes"]["Document"].pop("slot_usage")["license"]["range_expression"] + schema["slots"]["license"] = {"inlined": True, "any_of": [{"range": "Resource", "range_expression": expression}]} + assert conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "license"] .') + assert not conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Details; ex:category "license"] .') + + +@pytest.mark.parametrize( + "value,valid", [({"category": "mit"}, True), ({"category": "eula"}, True), ({"category": "gpl"}, False)] +) +def test_range_expression_combining_enums(schema: dict[str, Any], value: dict[str, Any], valid: bool) -> None: + """A range expression on values that aren't class instances can combine enums.""" + del schema["classes"]["Document"]["slot_usage"] + schema["enums"] = { + "Open": {"permissible_values": {"mit": {}, "bsd": {}}}, + "Proprietary": {"permissible_values": {"eula": {}}}, + } + schema["slots"]["category"]["range_expression"] = {"any_of": [{"is_a": "Open"}, {"is_a": "Proprietary"}]} + assert verdicts(schema, {"license": value}) == (valid, valid) + + +@pytest.mark.parametrize("in_class_expression", [False, True]) +@pytest.mark.parametrize("value,valid", [({"category": ["license"]}, True), ({"category": ["license", "x"]}, False)]) +def test_range_expression_uses_the_slots_of_the_range_class( + schema: dict[str, Any], value: dict[str, Any], valid: bool, in_class_expression: bool +) -> None: + """A condition constrains the slot as induced for the range class, whose ``slot_usage`` applies.""" + schema["classes"]["SpecialResource"] = {"is_a": "Resource", "slot_usage": {"category": {"multivalued": True}}} + schema["slots"]["license"]["range"] = "SpecialResource" + if in_class_expression: + del schema["classes"]["Document"]["slot_usage"] + condition = {"range_expression": _equals("category", "license")} + schema["classes"]["Document"]["all_of"] = [{"slot_conditions": {"license": condition}}] + else: + schema["classes"]["Document"]["slot_usage"]["license"]["range_expression"] = _equals("category", "license") + assert verdicts(schema, {"license": value}) == (valid, valid) + + +@pytest.mark.parametrize("target", ["Document", "SpecialDocument"]) +@pytest.mark.parametrize("value,valid", [({"category": "license"}, True), ({"category": "other"}, False)]) +def test_range_expression_is_inherited(schema: dict[str, Any], target: str, value: dict[str, Any], valid: bool) -> None: + """A subclass keeps the range expression its parent places on a slot.""" + schema["classes"]["SpecialDocument"] = {"is_a": "Document"} + assert verdicts(schema, {"license": value}, target=target) == (valid, valid) + + +@pytest.mark.parametrize("category,valid", [("license", True), ("other", False)]) +def test_range_expression_on_values_inlined_as_dict(schema: dict[str, Any], category: str, valid: bool) -> None: + """The values of a slot inlined as a dict are the values of the dict, not the dict itself.""" + schema["classes"]["Resource"]["slots"].insert(0, "id") + schema["slots"]["id"] = {"identifier": True, "range": "string"} + schema["slots"]["license"]["multivalued"] = True + json_schema = json.loads(JsonSchemaGenerator(yaml.safe_dump(schema), top_class="Document").serialize()) + instance = {"license": {"r1": {"category": "license"}, "r2": {"category": category}}} + assert Draft201909Validator(json_schema).is_valid(instance) is valid + data = 'ex:doc a ex:Document; ex:license ex:r1, ex:r2 . ex:r1 a ex:Resource; ex:category "license" . ' + assert conforms(schema, data + f'ex:r2 a ex:Resource; ex:category "{category}" .') is valid + + +@pytest.mark.parametrize( + "condition,licenses,valid", + [ + ({"minimum_cardinality": 1}, {"r1": {"category": "license"}}, True), + ({"minimum_cardinality": 1}, {}, False), + ({"maximum_cardinality": 1}, {"r1": {}, "r2": {}}, False), + ({"range_expression": _equals("category", "license")}, {"r1": {"category": "license"}}, True), + ({"range_expression": _equals("category", "license")}, {"r1": {"category": "other"}}, False), + ({"range": "SpecialResource"}, {"r1": {"code": 1}}, True), + ({"range": "SpecialResource"}, {"r1": {"category": "license"}}, False), + ], +) +def test_class_expression_on_values_inlined_as_dict( + schema: dict[str, Any], condition: dict[str, Any], licenses: dict[str, Any], valid: bool +) -> None: + """A condition of a class-level expression counts and constrains the values of a dict, as SHACL does.""" + del schema["classes"]["Document"]["slot_usage"] + schema["classes"]["Resource"]["slots"].insert(0, "id") + schema["classes"]["SpecialResource"] = {"is_a": "Resource", "slot_usage": {"code": {"required": True}}} + schema["slots"]["id"] = {"identifier": True, "range": "string"} + schema["slots"]["license"]["multivalued"] = True + schema["classes"]["Document"]["all_of"] = [{"slot_conditions": {"license": condition}}] + json_schema = json.loads(JsonSchemaGenerator(yaml.safe_dump(schema), top_class="Document").serialize()) + assert Draft201909Validator(json_schema).is_valid({"license": licenses}) is valid + + +def test_range_expression_follows_the_alias(schema: dict[str, Any]) -> None: + """A condition names a slot of the value, whose alias is its JSON key and whose IRI is its RDF property.""" + schema["slots"]["category"]["alias"] = "kind" + validator = Draft201909Validator( + json.loads(JsonSchemaGenerator(yaml.safe_dump(schema), top_class="Document").serialize()) + ) + assert validator.is_valid({"license": {"kind": "license"}}) + assert not validator.is_valid({"license": {"kind": "other"}}) + assert conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "license"] .') + assert not conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "other"] .') + + +@pytest.mark.parametrize("type_statement,expected", [("a ex:Resource;", True), ("", False), ("a ex:Details;", False)]) +def test_range_expression_preserves_class_membership( + schema: dict[str, Any], type_statement: str, expected: bool +) -> None: + """Matching content cannot substitute for membership in the declared range.""" + assert conforms(schema, f'ex:doc a ex:Document; ex:license [{type_statement} ex:category "license"] .') is expected + + +@pytest.mark.parametrize("category,expected", [("license", True), ("other", False)]) +@pytest.mark.parametrize("suffix", [None, "Shape"]) +@pytest.mark.parametrize("use_class_uri_names", [False, True]) +def test_range_expression_independent_of_shape_names( + schema: dict[str, Any], category: str, expected: bool, suffix: str | None, use_class_uri_names: bool +) -> None: + """The anonymous shape of a range expression works with either naming mode and a suffix.""" + data = f'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "{category}"] .' + assert conforms(schema, data, suffix=suffix, use_class_uri_names=use_class_uri_names) is expected + + +def test_range_expression_does_not_constrain_every_instance_of_the_range(schema: dict[str, Any]) -> None: + """Only the values of the constrained slot get the extra requirement.""" + assert conforms( + schema, + 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "license"] . ' + 'ex:other a ex:Resource; ex:category "other" .', + ) + + +@pytest.mark.parametrize("category,expected", [("license", True), ("other", False)]) +def test_range_expression_on_referenced_values(schema: dict[str, Any], category: str, expected: bool) -> None: + """In RDF, a value that is a reference is constrained by its description in the data graph.""" + schema["classes"]["Resource"]["slots"].append("id") + schema["slots"]["id"] = {"identifier": True, "range": "uriorcurie"} + schema["slots"]["license"]["inlined"] = False + data = f'ex:doc a ex:Document; ex:license ex:resource . ex:resource a ex:Resource; ex:category "{category}" .' + assert conforms(schema, data) is expected + + +def test_range_expression_uses_the_condition_range(schema: dict[str, Any]) -> None: + """A range expression in a condition with a ``range`` resolves its conditions in that range class.""" + schema["classes"]["SpecialResource"] = {"is_a": "Resource", "attributes": {"grade": {"range": "integer"}}} + del schema["classes"]["Document"]["slot_usage"] + expression = {"slot_conditions": {"grade": {"minimum_value": 2}}} + condition = {"range": "SpecialResource", "range_expression": expression} + schema["classes"]["Document"]["all_of"] = [{"slot_conditions": {"license": condition}}] + data = "ex:doc a ex:Document; ex:license [a ex:Resource, ex:SpecialResource; ex:grade {}] ." + assert conforms(schema, data.format(2)) + assert not conforms(schema, data.format(1)) + + +@pytest.mark.parametrize("category,expected", [("license", True), ("other", False)]) +def test_cli_enforces_range_expression(schema: dict[str, Any], tmp_path: Path, category: str, expected: bool) -> None: + """The command line generates the same shapes, without an option.""" + path = tmp_path / "context.yaml" + path.write_text(yaml.safe_dump(schema)) + result = CliRunner().invoke(cli, [str(path), "--non-closed"]) + assert result.exit_code == 0, result.output + data = PREFIX + f'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "{category}"] .' + report = validate( + data_graph=data, data_graph_format="turtle", shacl_graph=result.output, shacl_graph_format="turtle" + ) + assert report[0] is expected + + +@pytest.mark.parametrize("category,expected", [("license", True), ("other", False)]) +def test_range_expression_on_imported_slots( + schema: dict[str, Any], tmp_path: Path, category: str, expected: bool +) -> None: + """With imported shapes excluded, a condition still resolves the IRI of an imported slot.""" + imported = { + "id": "https://example.org/imported", + "name": "imported", + "default_prefix": "imp", + "prefixes": {"imp": "https://example.org/imported/"}, + "classes": {"Resource": {"class_uri": "imp:Resource", "slots": ["category"]}}, + "slots": {"category": {"slot_uri": "imp:category", "range": "string"}}, + } + (tmp_path / "imported.yaml").write_text(yaml.safe_dump(imported)) + del schema["classes"]["Resource"] + del schema["slots"]["category"] + schema["imports"].append("imported") + path = tmp_path / "context.yaml" + path.write_text(yaml.safe_dump(schema)) + shapes = ShaclGenerator(str(path), closed=False, exclude_imports=True).serialize() + graph = Graph().parse(data=shapes, format="turtle") + assert (None, SH.targetClass, URIRef("https://example.org/imported/Resource")) not in graph + data = ( + f"{PREFIX} @prefix imp: . " + f'ex:doc a ex:Document; ex:license [a imp:Resource; imp:category "{category}"] .' + ) + report = validate(data_graph=data, data_graph_format="turtle", shacl_graph=graph, meta_shacl=True) + assert report[0] is expected + + +@pytest.mark.parametrize( + "condition,reason", + [ + ({"unit": {"ucum_code": "m"}}, "'unit' in the condition on slot 'category'"), + ({"equals_expression": "{code} * 2"}, "'equals_expression' in the condition on slot 'category'"), + ], +) +def test_untranslatable_range_expression_skipped_with_warning( + schema: dict[str, Any], caplog: pytest.LogCaptureFixture, condition: dict[str, Any], reason: str +) -> None: + """A range expression SHACL cannot express is left out of the shapes of each class, with one warning.""" + expression = schema["classes"]["Document"]["slot_usage"]["license"]["range_expression"] + expression["slot_conditions"]["category"].update(condition) + schema["classes"]["SpecialDocument"] = {"is_a": "Document"} + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "other"] .') + assert [r.getMessage() for r in caplog.records] == [ + f"Slot 'license': range_expression is not translated to SHACL, because it uses {reason} " + "(in the shapes of 'Document', 'SpecialDocument')." + ] + + +def test_range_expression_with_conditions_on_values_that_are_not_class_instances( + schema: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """Values of a type or an enum have no slots for a condition to constrain.""" + del schema["classes"]["Document"]["slot_usage"] + schema["slots"]["category"]["range_expression"] = {"slot_conditions": {"code": {"required": True}}} + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert verdicts(schema, {"license": {"category": "other"}}) == (True, True) + assert [r.getMessage() for r in caplog.records] == [ + "Slot 'category': range_expression is not translated to SHACL, because it uses a condition on slot 'code' " + "of values that are not class instances (in the shapes of 'Resource')." + ] + + +def test_untranslatable_range_expression_in_class_expression_skips_it( + schema: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """A class-level expression whose range expression cannot be translated is skipped as a whole.""" + del schema["classes"]["Document"]["slot_usage"] + expression = {"slot_conditions": {"category": {"equals_string": "license", "unit": {"ucum_code": "m"}}}} + schema["classes"]["Document"]["all_of"] = [{"slot_conditions": {"license": {"range_expression": expression}}}] + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert conforms(schema, 'ex:doc a ex:Document; ex:license [a ex:Resource; ex:category "other"] .') + assert [r.getMessage() for r in caplog.records] == [ + "Class 'Document': all_of is not translated to SHACL, because it uses " + "'unit' in the condition on slot 'category'." + ] diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index fb236cab44..7c365de5b2 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -7017,7 +7017,7 @@ def test_class_expression_range_with_identifier_is_an_iri(): required: true""", ), "is_a-not-a-class": ( - "not a class", + "not a known class, type or enum", """ - is_a: Undefined - slot_conditions: From 479ad10350a9bf2320202437e1031b89cfe9e46e Mon Sep 17 00:00:00 2001 From: jdsika Date: Wed, 7 Oct 2026 23:09:30 +0200 Subject: [PATCH 28/30] feat(generators): enforce has_member alike in SHACL and JSON Schema has_member asks for at least one value of a multivalued slot satisfying an expression. gen-shacl ignored it. gen-json-schema translated it as contains, which an absent slot passes but an empty one fails, applied only the value operators of the expression, and ignored it in class-level expressions. Both generators now read a member as one value, which must satisfy the value operators, range and range_expression of the expression. A slot without a value has no member, so has_member decides presence. In SHACL it is an sh:qualifiedValueShape with sh:qualifiedMinCount 1; in JSON Schema, contains on a required slot, or some value of a dict inlined by key. In a slot condition the member takes the form of the condition. SHACL skips a has_member it cannot express with a warning. Signed-off-by: jdsika --- docs/generators/json-schema.rst | 3 + docs/generators/shacl.rst | 35 ++- docs/schemas/advanced.md | 4 +- .../generators/common/class_expression.py | 24 +- .../src/linkml/generators/jsonschemagen.py | 93 +++++- .../linkml/src/linkml/generators/shaclgen.py | 193 ++++++++---- .../test_boolean_slot_compliance.py | 3 + ...chema_multivalued_element_constraints.yaml | 44 --- .../jsonschema_multivalued_has_member.yaml | 80 +++++ .../linkml/test_generators/test_has_member.py | 277 ++++++++++++++++++ .../test_generators/test_jsonschemagen.py | 6 + .../test_generators/test_range_expression.py | 36 +-- tests/linkml/test_generators/test_shaclgen.py | 20 +- tests/linkml/utils/generator_agreement.py | 54 ++++ 14 files changed, 708 insertions(+), 164 deletions(-) create mode 100644 tests/linkml/test_generators/input/jsonschema_multivalued_has_member.yaml create mode 100644 tests/linkml/test_generators/test_has_member.py create mode 100644 tests/linkml/utils/generator_agreement.py diff --git a/docs/generators/json-schema.rst b/docs/generators/json-schema.rst index 14f9501e8d..a7bda92f80 100644 --- a/docs/generators/json-schema.rst +++ b/docs/generators/json-schema.rst @@ -138,6 +138,9 @@ false, as described under "Class-level expressions and absent slots" in A slot's `range_expression `_ constrains each of its values in the same way, with its conditions on the slots of the value. +A multivalued slot's `has_member `_ becomes +``contains``, and the slot is required, because a slot without a value has no +member. The SHACL generator reads them the same way. Inlining diff --git a/docs/generators/shacl.rst b/docs/generators/shacl.rst index 150d5b46c1..f17456b41e 100644 --- a/docs/generators/shacl.rst +++ b/docs/generators/shacl.rst @@ -214,7 +214,9 @@ expressions recurse. Each entry of ``slot_conditions`` gives an ``meaning`` where they have one; * ``range`` gives the same class, type or enum constraint as a slot's range; * ``range_expression`` gives an ``sh:node``, as described under - `Range Expressions`_. + `Range Expressions`_; +* ``has_member`` gives an ``sh:qualifiedValueShape``, as described under + `Members`_. A shape may have at most one value of ``sh:minInclusive``, ``sh:maxInclusive`` or ``sh:in``, and of ``sh:pattern``, whose component also takes ``sh:flags`` @@ -264,7 +266,7 @@ slot's range. An operator whose members use anything else is skipped as a whole and logged as a warning, because leaving out one member would change what the operator -admits. That covers, for example, ``has_member``, ``equals_expression`` or a +admits. That covers, for example, ``all_members``, ``equals_expression`` or a slot-level ``any_of`` inside a slot condition, a condition on a name that is not a slot, a condition on the identifier slot (the node's IRI rather than a property), and ``equals_string`` on a slot whose range does not hold strings. @@ -328,6 +330,35 @@ graph, as ``sh:class`` is. The JSON Schema generator can't check it, because there the value is just an identifier. +Members +^^^^^^^ + +A multivalued slot's `has_member `__ asks +for at least one value that satisfies an expression. It becomes an +``sh:qualifiedValueShape`` with ``sh:qualifiedMinCount 1`` (`SHACL §4.7.3 +`__), so +the other values need only satisfy the slot's own constraints. The member +expression constrains one value: its value operators, ``range`` and +``range_expression`` apply. + +.. code-block:: yaml + + slots: + tags: + range: string + multivalued: true + has_member: + equals_string: reviewed + +This accepts ``[reviewed, pending]`` and rejects ``[pending]``. A slot without a +value, absent or empty, has no member, so it fails ``has_member``, as the JSON +Schema generator also reads it. In a slot condition of a class-level expression, +``has_member`` therefore decides whether the slot may be absent, and the member +takes the form of the condition. Several conditions with ``has_member`` may be +satisfied by the same value. A ``has_member`` on a slot that isn't multivalued, +or one that uses anything else, is skipped and logged as a warning. + + Command Line ^^^^^^^^^^^^ diff --git a/docs/schemas/advanced.md b/docs/schemas/advanced.md index 297575ed60..2b59901638 100644 --- a/docs/schemas/advanced.md +++ b/docs/schemas/advanced.md @@ -70,7 +70,7 @@ At the class level, each member of `any_of`, `all_of`, `exactly_one_of` and `non A slot condition needs a meaning when its slot is absent. The JSON Schema and SHACL generators read it as an SQL `CHECK` constraint reads a condition on a null value: an instance is invalid only when an expression is definitely false. -- A condition that decides whether its slot may be absent is true or false as usual. Such a condition sets `value_presence: PRESENT` or `ABSENT`, `required: true`, a minimum or exact cardinality of at least 1, or a maximum or exact cardinality of 0. +- A condition that decides whether its slot may be absent is true or false as usual. Such a condition sets `value_presence: PRESENT` or `ABSENT`, `required: true`, a minimum or exact cardinality of at least 1, `has_member`, or a maximum or exact cardinality of 0. - Any other condition is *unknown* when its slot is absent. - `any_of`, `all_of` and `none_of` combine these as "or", "and" and "not". An unknown member doesn't make `any_of` true, nor `none_of` false. - `exactly_one_of` holds when exactly one member is definitely true. @@ -94,6 +94,8 @@ A `Sample` without `status` is valid: the condition is unknown, so `none_of` isn A slot's [range_expression](https://w3id.org/linkml/range_expression) constrains each value of the slot in the same way: its conditions constrain the slots of the value, as induced for the slot's range class. In a slot condition, a `range_expression` is part of the condition, so under `none_of` it must hold definitely. +A slot's [has_member](https://w3id.org/linkml/has_member) asks for at least one value satisfying its expression. A slot without a value, absent or empty, has no member, so it fails `has_member`. + [Rules](#rules) are read differently: their preconditions require their slots, and so do their postconditions unless the rule is `open_world`. ### Unions as ranges diff --git a/packages/linkml/src/linkml/generators/common/class_expression.py b/packages/linkml/src/linkml/generators/common/class_expression.py index 21ff99af4f..e5fadb5f39 100644 --- a/packages/linkml/src/linkml/generators/common/class_expression.py +++ b/packages/linkml/src/linkml/generators/common/class_expression.py @@ -42,10 +42,10 @@ def states_presence(condition: SlotDefinition) -> bool: Such a condition is definitely true or false for an absent slot: ``value_presence: PRESENT`` or ``ABSENT``; ``required: true``, unless ``value_presence`` overrides it; a minimum or exact cardinality of at least - 1, which an absent slot fails; and a maximum or exact cardinality of 0, - which it satisfies. Other bounds, ``required: false`` and ``UNCOMMITTED`` - leave absence open, so adding one never changes the verdict on an absent - slot. + 1, and ``has_member``, which asks for at least one value, all of which an + absent slot fails; and a maximum or exact cardinality of 0, which it + satisfies. Other bounds, ``required: false`` and ``UNCOMMITTED`` leave + absence open, so adding one never changes the verdict on an absent slot. >>> states_presence(SlotDefinition("label", equals_string="A")) False @@ -57,12 +57,16 @@ def states_presence(condition: SlotDefinition) -> bool: True >>> states_presence(SlotDefinition("tags", minimum_cardinality=1)) True + >>> states_presence(SlotDefinition("tags", has_member={"equals_string": "A"})) + True """ if condition.value_presence is not None: if condition.value_presence in (_PRESENT, _ABSENT): return True elif condition.required: return True + if condition.has_member is not None: + return True lower = (condition.minimum_cardinality, condition.exact_cardinality) upper = (condition.maximum_cardinality, condition.exact_cardinality) return any(bound is not None and bound >= 1 for bound in lower) or 0 in upper @@ -71,9 +75,13 @@ def states_presence(condition: SlotDefinition) -> bool: def value_bounds(condition: SlotDefinition, definite: bool) -> tuple[int, int | None]: """The least and the greatest number of values *condition* allows its slot. - ``value_presence`` takes precedence over ``required``. In the "definitely - true" form (*definite*), a condition that doesn't state presence also - requires the slot. The greatest number is ``None`` when unbounded. + ``value_presence`` takes precedence over ``required``, and ``has_member`` + asks for at least one value. In the "definitely true" form (*definite*), a + condition that doesn't state presence also requires the slot. The greatest + number is ``None`` when unbounded. + + >>> value_bounds(SlotDefinition("tags", has_member={"equals_string": "A"}), definite=False) + (1, None) """ lower, upper = [0], [] if condition.value_presence is not None: @@ -83,6 +91,8 @@ def value_bounds(condition: SlotDefinition, definite: bool) -> tuple[int, int | upper.append(0) elif condition.required: lower.append(1) + if condition.has_member is not None: + lower.append(1) if definite and not states_presence(condition): lower.append(1) for bound, target in ( diff --git a/packages/linkml/src/linkml/generators/jsonschemagen.py b/packages/linkml/src/linkml/generators/jsonschemagen.py index 6b1452e9a7..fd5cfc236d 100644 --- a/packages/linkml/src/linkml/generators/jsonschemagen.py +++ b/packages/linkml/src/linkml/generators/jsonschemagen.py @@ -688,7 +688,8 @@ def get_subschema_for_class_expression( Each slot condition constrains the slot as induced for *cls*, so ``slot_usage`` applies. Its values must satisfy its value operators, its ``range`` and its ``range_expression``, the latter in the form of - *expr*; the number of values must lie within + *expr*, and one of them its ``has_member``; the number of values must + lie within :func:`~linkml.generators.common.class_expression.value_bounds`, where an empty list counts as no value. *definite* selects the form, as in :meth:`get_subschema_for_class_operator`. *cls* is ``None`` for values @@ -702,26 +703,22 @@ def get_subschema_for_class_expression( prop_name = self._curie(slot) else: prop_name = self.aliased_slot_name(slot) - value_condition = condition - if condition.range_expression is not None: - # translated below, in the form of *expr* - value_condition = copy(condition) - value_condition.range_expression = None - values = self.get_subschema_for_slot(value_condition, omit_type=True, include_null=False) - if condition.range_expression is not None: - expression = self.get_subschema_for_range_expression( - condition.range or slot.range, condition.range_expression, definite - ) - values = JsonSchema({"allOf": [values, expression]}) if values else expression + values = self._value_subschema(slot, condition, definite) lower, upper = value_bounds(condition, definite) + member = None + if slot.multivalued and condition.has_member is not None: + member = self.get_subschema_for_member(slot, condition.has_member, definite) if slot.multivalued and self._inlined_as_dict(slot): prop = JsonSchema({"type": "object", "additionalProperties": values}) prop.add_keyword("minProperties", lower or None) prop.add_keyword("maxProperties", upper) + if member is not None: + prop.setdefault("allOf", []).append(self._some_dict_value(member)) elif slot.multivalued: prop = JsonSchema.array_of(values, include_null=False, required=False) prop.add_keyword("minItems", lower or None) prop.add_keyword("maxItems", upper) + prop.add_keyword("contains", member) else: prop = values if condition.range is not None: @@ -763,6 +760,62 @@ def _inlined_as_dict(self, slot: SlotDefinition) -> bool: and get_range_associated_slots(self.schemaview, slot.range)[0] is not None ) + def _value_subschema( + self, + slot: SlotDefinition | AnonymousSlotExpression, + condition: SlotDefinition | AnonymousSlotExpression, + definite: bool, + ) -> JsonSchema: + """The subschema each value of *slot* must satisfy under the value operators of *condition*. + + These include its ``range_expression``, in the form *definite*, but not + its ``range``, which depends on how the values of *slot* are represented. + """ + value_condition = condition + if condition.range_expression is not None: + # translated below, in the form *definite* + value_condition = copy(condition) + value_condition.range_expression = None + values = self.get_subschema_for_slot(value_condition, omit_type=True, include_null=False) + if condition.range_expression is not None: + expression = self.get_subschema_for_range_expression( + condition.range or slot.range, condition.range_expression, definite + ) + values = JsonSchema({"allOf": [values, expression]}) if values else expression + return values + + def get_subschema_for_member( + self, slot: SlotDefinition | AnonymousSlotExpression, member: AnonymousSlotExpression, definite: bool + ) -> JsonSchema: + """The subschema a value of multivalued *slot* satisfies as a member of its ``has_member`` *member*. + + A member is one value, which must satisfy the value operators, the + ``range`` and the ``range_expression`` of *member*, the latter in the + form *definite*. + """ + conjuncts = [self._value_subschema(slot, member, definite)] + if member.range is not None: + conjuncts.append(self._value_type_subschema(slot, member.range)) + conjuncts = [conjunct for conjunct in conjuncts if conjunct] + if len(conjuncts) == 1: + return conjuncts[0] + return JsonSchema({"allOf": conjuncts} if conjuncts else {}) + + def _value_type_subschema(self, slot: SlotDefinition | AnonymousSlotExpression, value_range: str) -> JsonSchema: + """The subschema of one value of range *value_range* as multivalued *slot* represents its values.""" + collection = self.get_subschema_for_slot( + AnonymousSlotExpression( + range=value_range, multivalued=True, inlined=slot.inlined, inlined_as_list=slot.inlined_as_list + ), + include_null=False, + ) + return collection["additionalProperties"] if collection.is_object else collection["items"] + + @staticmethod + def _some_dict_value(member: JsonSchema) -> JsonSchema: + """The subschema of a dict with a value satisfying *member*: not every value fails it.""" + return JsonSchema({"not": {"additionalProperties": {"not": member}}}) + def _class_expression_slot(self, cls: ClassDefinition | None, slot_name: str) -> SlotDefinition | None: """The slot a condition of a class expression of *cls* names, as induced for *cls*, if any.""" sv = self.schemaview @@ -1104,17 +1157,21 @@ def get_subschema_for_slot( if prop.is_array: all_element_constraints = self.get_value_constraints_for_slot(slot.all_members) - any_element_constraints = self.get_value_constraints_for_slot(slot.has_member) prop.add_keyword("minItems", slot.minimum_cardinality) prop.add_keyword("maxItems", slot.maximum_cardinality) prop["items"].update(own_constraints) prop["items"].update(all_element_constraints) - if any_element_constraints: - prop["contains"] = any_element_constraints + if slot.has_member is not None: + # At least one value, so not null. + prop["type"] = "array" + prop["contains"] = self.get_subschema_for_member(slot, slot.has_member, definite=False) elif inlined_as_dict: # The values of the slot are the values of the dict. if own_constraints: prop["additionalProperties"] = JsonSchema({"allOf": [prop["additionalProperties"], own_constraints]}) + if slot.has_member is not None: + member = self.get_subschema_for_member(slot, slot.has_member, definite=False) + prop.setdefault("allOf", []).append(self._some_dict_value(member)) else: prop.update(own_constraints) @@ -1178,7 +1235,11 @@ def handle_class_slot(self, subschema: JsonSchema, cls: ClassDefinition, slot: S slot = self.before_generate_class_slot(slot, cls, self.schemaview) class_id_slot = self.schemaview.get_identifier_slot(cls.name, use_key=True) value_required = ( - slot.required or slot == class_id_slot or slot.value_presence == PresenceEnum(PresenceEnum.PRESENT) + slot.required + or slot == class_id_slot + or slot.value_presence == PresenceEnum(PresenceEnum.PRESENT) + # a member is a value + or (slot.multivalued and slot.has_member is not None) ) value_disallowed = slot.value_presence == PresenceEnum(PresenceEnum.ABSENT) diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index 9f2f1ca12f..ec7d8d12dd 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -271,7 +271,7 @@ def as_graph(self) -> Graph: self._class_expressions_added: set[tuple[URIRef, str, str]] = set() self._class_expression_problems: dict[tuple[str, str, str], list[str]] = {} - self._range_expression_problems: dict[tuple[str, str], list[str]] = {} + self._slot_expression_problems: dict[tuple[str, str, str], list[str]] = {} for c in sv.all_classes(imports=not self.exclude_imports).values(): def shape_pv(p, v): @@ -428,6 +428,8 @@ def branch_pv(p, v): if s.range_expression is not None: self._add_range_expression(g, prop_pv, c, s, s.range, s.range_expression) + if s.has_member is not None: + self._add_member(g, prop_pv, c, s) if s.annotations and self.include_annotations: self._add_annotations(prop_pv, s) @@ -443,7 +445,7 @@ def branch_pv(p, v): self._report_rule_problems() self._report_class_expression_problems() - self._report_range_expression_problems() + self._report_slot_expression_problems() return g LINKML_ANY_URI = "https://w3id.org/linkml/Any" @@ -478,7 +480,9 @@ def branch_pv(p, v): "range_expression", } ) - _SLOT_CONDITION_FIELDS = _SLOT_CONDITION_PRESENCE_FIELDS | _SLOT_CONDITION_VALUE_FIELDS + # has_member, which asks for at least one value satisfying a value expression, + # does both. + _SLOT_CONDITION_FIELDS = _SLOT_CONDITION_PRESENCE_FIELDS | _SLOT_CONDITION_VALUE_FIELDS | {"has_member"} # Parameters a shape may have at most one value of: sh:minInclusive and # sh:maxInclusive (SHACL §4.3), sh:in (§4.8.3), sh:datatype and sh:nodeKind @@ -656,15 +660,7 @@ def _slot_condition_shape( "definitely true" form with *definite*, otherwise its "not false" form.""" slot = self._condition_slot(cls, slot_name) pnode = BNode() - repeated = [] - - def prop_pv(p, v): - if v is None: - return - if p in self._SINGLE_VALUE_PARAMETERS and (pnode, p, None) in g: - repeated.append((p, v)) - else: - g.add((pnode, p, v)) + prop_pv, finish = self._shape_parameters(g, pnode) prop_pv(SH.path, URIRef(self._slot_iri(slot))) if condition.title is not None: @@ -678,13 +674,62 @@ def prop_pv(p, v): if upper is not None: prop_pv(SH.maxCount, Literal(upper)) + self._add_value_constraints(g, prop_pv, condition, condition.range or slot.range, definite) + if condition.has_member is not None: + prop_pv(SH.qualifiedValueShape, self._member_shape(g, slot, condition.has_member, definite)) + prop_pv(SH.qualifiedMinCount, Literal(1)) + finish() + return pnode + + def _shape_parameters(self, g: Graph, node: BNode) -> tuple[Callable, Callable[[], None]]: + """A function adding a parameter to the shape *node*, and one to call once all are added. + + A shape may have at most one value of each of :attr:`_SINGLE_VALUE_PARAMETERS`. + A further value goes into an ``sh:and`` member shape of its own, where it + applies to the same values, as it would on *node* itself. + """ + repeated = [] + + def add(p, v): + if v is None: + return + if p in self._SINGLE_VALUE_PARAMETERS and (node, p, None) in g: + repeated.append((p, v)) + else: + g.add((node, p, v)) + + def finish(): + if repeated: + members = [] + for p, v in repeated: + member = BNode() + g.add((member, p, v)) + members.append(member) + and_node = BNode() + Collection(g, and_node, members) + g.add((node, SH["and"], and_node)) + + return add, finish + + def _add_value_constraints( + self, + g: Graph, + pv: Callable, + condition: SlotDefinition | AnonymousSlotExpression, + value_range: ElementName | None, + definite: bool, + ) -> None: + """Add the constraints that *condition* places on each value of range *value_range*. + + These are the value operators, ``range`` and ``range_expression``, the + latter in its "definitely true" form with *definite*. + """ if condition.minimum_value is not None: - prop_pv(SH.minInclusive, Literal(condition.minimum_value)) + pv(SH.minInclusive, Literal(condition.minimum_value)) if condition.maximum_value is not None: - prop_pv(SH.maxInclusive, Literal(condition.maximum_value)) + pv(SH.maxInclusive, Literal(condition.maximum_value)) if condition.pattern is not None: - prop_pv(SH.pattern, Literal(condition.pattern)) - value_range = condition.range or slot.range + pv(SH.pattern, Literal(condition.pattern)) for values in ( [condition.equals_string] if condition.equals_string is not None else [], condition.equals_string_in, @@ -692,29 +737,31 @@ def prop_pv(p, v): if values: in_node = BNode() Collection(g, in_node, self._string_value_terms(value_range, values)) - prop_pv(SH["in"], in_node) + pv(SH["in"], in_node) if condition.equals_number is not None: # A value comparison, unlike the slot loop's sh:hasValue: 5 matches 5.0, # and like every other value constraint in a condition it holds when the # slot is absent. - prop_pv(SH.minInclusive, Literal(condition.equals_number)) - prop_pv(SH.maxInclusive, Literal(condition.equals_number)) + pv(SH.minInclusive, Literal(condition.equals_number)) + pv(SH.maxInclusive, Literal(condition.equals_number)) if condition.range is not None: - self._add_range(g, prop_pv, condition.range) + self._add_range(g, pv, condition.range) if condition.range_expression is not None: - prop_pv(SH.node, self._range_expression_shape(g, value_range, condition.range_expression, definite)) - if repeated: - # Each repeated parameter in a member shape of its own: all of them hold - # for every value, as they would on the property shape itself. - members = [] - for p, v in repeated: - member = BNode() - g.add((member, p, v)) - members.append(member) - and_node = BNode() - Collection(g, and_node, members) - g.add((pnode, SH["and"], and_node)) - return pnode + pv(SH.node, self._range_expression_shape(g, value_range, condition.range_expression, definite)) + + def _member_shape(self, g: Graph, slot: SlotDefinition, member: AnonymousSlotExpression, definite: bool) -> BNode: + """Build the node shape a value of *slot* must conform to as a member of its ``has_member``. + + ``sh:qualifiedValueShape`` with ``sh:qualifiedMinCount 1`` (SHACL §4.7.3) + then asks for at least one such value, while the other values need only + satisfy the slot's own constraints. *definite* selects the form of a + ``range_expression`` of *member*. + """ + node = BNode() + pv, finish = self._shape_parameters(g, node) + self._add_value_constraints(g, pv, member, member.range or slot.range, definite) + finish() + return node def _string_value_terms(self, r: ElementName | None, values: list[str]) -> list[URIRef | Literal]: """The RDF terms of the ``equals_string`` / ``equals_string_in`` *values* of a slot with range *r*. @@ -790,17 +837,13 @@ def _untranslatable(self, cls: ClassDefinition | None, expr: AnonymousClassExpre if slot.identifier: # An identifier is the node's IRI, not a property arc. return f"a condition on the identifier slot '{slot_name}'" - if condition.range is not None and not self._is_known_range(condition.range): - return f"the unknown range '{condition.range}' in the condition on slot '{slot_name}'" - value_range = condition.range or slot.range - if condition.range_expression is not None: - reason = self._untranslatable(self._value_class(value_range), condition.range_expression) + reason = self._value_untranslatable(condition, slot, f"the condition on slot '{slot_name}'") + if reason is not None: + return reason + if condition.has_member is not None: + reason = self._member_untranslatable(slot, condition.has_member) if reason is not None: return reason - if (condition.equals_string is not None or condition.equals_string_in) and not self._is_string_range( - value_range - ): - return f"equals_string on slot '{slot_name}', whose range '{value_range}' does not hold strings" for operator in self._CLASS_EXPRESSION_OPERATORS: for member in getattr(expr, operator) or []: reason = self._untranslatable(cls, member) @@ -808,6 +851,38 @@ def _untranslatable(self, cls: ClassDefinition | None, expr: AnonymousClassExpre return reason return None + def _value_untranslatable( + self, condition: SlotDefinition | AnonymousSlotExpression, slot: SlotDefinition, where: str + ) -> str | None: + """Return what in the value constraints of *condition* on *slot* cannot be translated, or ``None``. + + *where* names *condition* in the explanation. + """ + if condition.range is not None and not self._is_known_range(condition.range): + return f"the unknown range '{condition.range}' in {where}" + value_range = condition.range or slot.range + if condition.range_expression is not None: + reason = self._untranslatable(self._value_class(value_range), condition.range_expression) + if reason is not None: + return reason + if (condition.equals_string is not None or condition.equals_string_in) and not self._is_string_range( + value_range + ): + return f"equals_string in {where}, whose range '{value_range}' does not hold strings" + return None + + def _member_untranslatable(self, slot: SlotDefinition, member: AnonymousSlotExpression) -> str | None: + """Return what in the ``has_member`` *member* of *slot* cannot be translated, or ``None``. + + A member is one value, so only value constraints apply to it. + """ + if not slot.multivalued: + return f"has_member on slot '{slot.name}', which is not multivalued" + unknown = self._set_operator_fields(member) - self._SLOT_CONDITION_VALUE_FIELDS + if unknown: + return f"'{sorted(unknown)[0]}' in the has_member of slot '{slot.name}'" + return self._value_untranslatable(member, slot, f"the has_member of slot '{slot.name}'") + def _value_class(self, value_range: ElementName | None) -> ClassDefinition | None: """The class whose instances a range *value_range* holds, or ``None`` for any other range.""" sv = self.schemaview @@ -843,19 +918,37 @@ def _add_range_expression( """ reason = self._untranslatable(self._value_class(value_range), expression) if reason is not None: - classes = self._range_expression_problems.setdefault((slot.name, reason), []) - if cls.name not in classes: - classes.append(cls.name) + self._slot_expression_problem(cls, slot, "range_expression", reason) return prop_pv(SH.node, self._range_expression_shape(g, value_range, expression, definite=False)) - def _report_range_expression_problems(self) -> None: - """Warn once about each ``range_expression`` of a slot that is not translated, - naming the class shapes it is missing from.""" - for (slot_name, reason), classes in self._range_expression_problems.items(): + def _add_member(self, g: Graph, prop_pv: Callable, cls: ClassDefinition, slot: SlotDefinition) -> None: + """Ask for at least one value of *slot*, in the shape of *cls*, satisfying its ``has_member``. + + Having no value, an absent slot fails it. A ``has_member`` that cannot be + translated is skipped with a warning. + """ + reason = self._member_untranslatable(slot, slot.has_member) + if reason is not None: + self._slot_expression_problem(cls, slot, "has_member", reason) + return + prop_pv(SH.qualifiedValueShape, self._member_shape(g, slot, slot.has_member, definite=False)) + prop_pv(SH.qualifiedMinCount, Literal(1)) + + def _slot_expression_problem(self, cls: ClassDefinition, slot: SlotDefinition, field: str, reason: str) -> None: + """Record that *field* of *slot* is left out of the shape of *cls* because of *reason*.""" + classes = self._slot_expression_problems.setdefault((slot.name, field, reason), []) + if cls.name not in classes: + classes.append(cls.name) + + def _report_slot_expression_problems(self) -> None: + """Warn once about each ``range_expression`` or ``has_member`` of a slot that is + not translated, naming the class shapes it is missing from.""" + for (slot_name, field, reason), classes in self._slot_expression_problems.items(): logger.warning( - "Slot %r: range_expression is not translated to SHACL, because it uses %s (in the shapes of %s).", + "Slot %r: %s is not translated to SHACL, because it uses %s (in the shapes of %s).", slot_name, + field, reason, ", ".join(map(repr, classes)), ) diff --git a/tests/linkml/test_compliance/test_boolean_slot_compliance.py b/tests/linkml/test_compliance/test_boolean_slot_compliance.py index 8167f7ae95..1b1f82d592 100644 --- a/tests/linkml/test_compliance/test_boolean_slot_compliance.py +++ b/tests/linkml/test_compliance/test_boolean_slot_compliance.py @@ -2823,6 +2823,9 @@ def test_membership(framework, name, quantification, expression, instance, is_va expected_behavior = ValidationBehavior.INCOMPLETE if framework in [SHACL, SQL_DDL_SQLITE, PANDERA_POLARS_CLASS]: expected_behavior = ValidationBehavior.INCOMPLETE + if framework == SHACL and quantification == "has_member" and s1_range != CLASS_D: + # sh:qualifiedValueShape; equals_string on a reference is not translated + expected_behavior = ValidationBehavior.IMPLEMENTS if framework == OWL and name == "all_obj_members_equals_string" and not is_valid: # This test case relies on punning, as s1 is used as both an OP and DP, # so we do not expect a DL-reasoner to be able to handle it diff --git a/tests/linkml/test_generators/input/jsonschema_multivalued_element_constraints.yaml b/tests/linkml/test_generators/input/jsonschema_multivalued_element_constraints.yaml index d9a4667ace..78b7c26703 100644 --- a/tests/linkml/test_generators/input/jsonschema_multivalued_element_constraints.yaml +++ b/tests/linkml/test_generators/input/jsonschema_multivalued_element_constraints.yaml @@ -24,25 +24,12 @@ schema: minimum_value: 2 maximum_value: 5 - int_list_with_has_member: - range: integer - multivalued: true - has_member: - minimum_value: 2 - maximum_value: 5 - string_list_with_all_members: range: string multivalued: true all_members: pattern: e.* - string_list_with_has_member: - range: string - multivalued: true - has_member: - pattern: e.* - classes: Test: tree_root: true @@ -50,9 +37,7 @@ schema: - int_list - string_list - int_list_with_all_members - - int_list_with_has_member - string_list_with_all_members - - string_list_with_has_member json_schema: properties: int_list: @@ -69,19 +54,10 @@ json_schema: minimum: 2 maximum: 5 type: array - int_list_with_has_member: - contains: - minimum: 2 - maximum: 5 - type: array string_list_with_all_members: items: pattern: e.* type: array - string_list_with_has_member: - contains: - pattern: e.* - type: array data_cases: - data: int_list: [2, 3, 4, 5] @@ -105,13 +81,6 @@ data_cases: - data: int_list_with_all_members: [1, 2, 3] error_message: Failed validating 'minimum' - - data: - int_list_with_has_member: [2, 3, 4, 5] - - data: - int_list_with_has_member: [0, 1, 2] - - data: - int_list_with_has_member: [6, 7, 8] - error_message: Failed validating 'contains' - data: string_list_with_all_members: - echo @@ -121,16 +90,3 @@ data_cases: - echo - foxtrot error_message: Failed validating 'pattern' - - data: - string_list_with_has_member: - - echo - - elephant - - data: - string_list_with_has_member: - - echo - - foxtrot - - data: - string_list_with_has_member: - - foxtrot - - golf - error_message: Failed validating 'contains' diff --git a/tests/linkml/test_generators/input/jsonschema_multivalued_has_member.yaml b/tests/linkml/test_generators/input/jsonschema_multivalued_has_member.yaml new file mode 100644 index 0000000000..ce52cf7a38 --- /dev/null +++ b/tests/linkml/test_generators/input/jsonschema_multivalued_has_member.yaml @@ -0,0 +1,80 @@ +schema: + id: http://example.org/jsonschema_multivalued_has_member + name: jsonschema_multivalued_has_member + + imports: + - https://w3id.org/linkml/types + + slots: + int_list_with_has_member: + range: integer + multivalued: true + has_member: + minimum_value: 2 + maximum_value: 5 + + string_list_with_has_member: + range: string + multivalued: true + has_member: + pattern: e.* + + classes: + Test: + tree_root: true + slots: + - int_list_with_has_member + - string_list_with_has_member +json_schema: + properties: + int_list_with_has_member: + contains: + minimum: 2 + maximum: 5 + type: array + string_list_with_has_member: + contains: + pattern: e.* + type: array + required: + - int_list_with_has_member + - string_list_with_has_member +data_cases: + - data: + int_list_with_has_member: [2, 3, 4, 5] + string_list_with_has_member: [echo] + - data: + int_list_with_has_member: [0, 1, 2] + string_list_with_has_member: [echo] + - data: + int_list_with_has_member: [6, 7, 8] + string_list_with_has_member: [echo] + error_message: Failed validating 'contains' + - data: + int_list_with_has_member: [2] + string_list_with_has_member: + - echo + - elephant + - data: + int_list_with_has_member: [2] + string_list_with_has_member: + - echo + - foxtrot + - data: + int_list_with_has_member: [2] + string_list_with_has_member: + - foxtrot + - golf + error_message: Failed validating 'contains' + # a member is a value, so a list without a value has no member + - data: + int_list_with_has_member: [] + string_list_with_has_member: [echo] + error_message: Failed validating 'contains' + - data: + int_list_with_has_member: null + string_list_with_has_member: [echo] + error_message: Failed validating 'type' + - data: + string_list_with_has_member: [echo] + error_message: Failed validating 'required' diff --git a/tests/linkml/test_generators/test_has_member.py b/tests/linkml/test_generators/test_has_member.py new file mode 100644 index 0000000000..63ddc02c60 --- /dev/null +++ b/tests/linkml/test_generators/test_has_member.py @@ -0,0 +1,277 @@ +"""``has_member`` asks for a value satisfying an expression alike in the JSON Schema and SHACL generators. + +A member is one value of a multivalued slot, so a slot without a value, absent +or empty, has no member. In a condition of a class-level expression, +``has_member`` therefore decides whether the slot may be absent, and its member +takes the form of the condition. The agreement tests validate the same +instance with both generated artifacts, loading it as RDF through the +generated JSON-LD context. +""" + +import json +import logging +from typing import Any + +import pytest +import yaml +from jsonschema import Draft201909Validator +from pyshacl import validate + +from linkml.generators.jsonschemagen import JsonSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator +from tests.linkml.utils import generator_agreement + +PREFIX = "@prefix ex: . " + + +@pytest.fixture +def schema() -> dict[str, Any]: + """A collection of tags, scores and inlined items.""" + return yaml.safe_load(""" +id: https://example.org/membership +name: membership +prefixes: + ex: https://example.org/membership/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +imports: [linkml:types] +classes: + Collection: + tree_root: true + slots: [tags, scores, items] + Item: + slots: [status] +slots: + tags: {range: string, multivalued: true} + scores: {range: integer, multivalued: true} + items: {range: Item, multivalued: true, inlined_as_list: true} + status: {range: string} +""") + + +def verdicts(schema: dict[str, Any], instance: dict[str, Any]) -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL shapes generated for *schema* accept *instance* of ``Collection``.""" + return generator_agreement.verdicts(schema, instance, "Collection") + + +def conforms(schema: dict[str, Any], data: str) -> bool: + """Whether Turtle *data* conforms to the SHACL shapes generated for *schema*, which must pass meta-SHACL.""" + shapes = ShaclGenerator(yaml.safe_dump(schema), closed=False).serialize() + return validate( + data_graph=PREFIX + data, + data_graph_format="turtle", + shacl_graph=shapes, + shacl_graph_format="turtle", + meta_shacl=True, + )[0] + + +def json_valid(schema: dict[str, Any], instance: dict[str, Any]) -> bool: + """Whether the JSON Schema generated for *schema* accepts *instance* of ``Collection``.""" + json_schema = json.loads(JsonSchemaGenerator(yaml.safe_dump(schema), top_class="Collection").serialize()) + return Draft201909Validator(json_schema).is_valid(instance) + + +_READY = {"range_expression": {"slot_conditions": {"status": {"equals_string": "ready"}}}} + + +@pytest.mark.parametrize( + "slot,member,values,valid", + [ + pytest.param("tags", {"equals_string": "reviewed"}, ["reviewed", "pending"], True, id="match"), + pytest.param("tags", {"equals_string": "reviewed"}, ["pending"], False, id="no-match"), + # a slot without a value has no member + pytest.param("tags", {"equals_string": "reviewed"}, [], False, id="empty"), + pytest.param("tags", {"equals_string": "reviewed"}, None, False, id="absent"), + pytest.param("tags", {}, ["anything"], True, id="any-value"), + pytest.param("tags", {}, None, False, id="any-value-absent"), + pytest.param("tags", {"equals_string_in": ["a", "b"], "pattern": "^a"}, ["a", "b"], True, id="conjunction"), + pytest.param("tags", {"equals_string_in": ["a", "b"], "pattern": "^a"}, ["b"], False, id="conjunction-other"), + pytest.param("scores", {"minimum_value": 10}, [9, 10], True, id="bound"), + pytest.param("scores", {"minimum_value": 10}, [8, 9], False, id="bound-other"), + # two values of one SHACL parameter hold together + pytest.param("scores", {"minimum_value": 0, "equals_number": 0}, [-1, 0], True, id="repeated"), + pytest.param("scores", {"minimum_value": 0, "equals_number": 0}, [-1, 1], False, id="repeated-other"), + pytest.param("scores", {"minimum_value": 5, "equals_number": 3}, [3, 5], False, id="contradiction"), + pytest.param("scores", {"minimum_value": 5, "equals_number": 7}, [6], False, id="repeated-second"), + pytest.param("items", _READY, [{"status": "draft"}, {"status": "ready"}], True, id="range_expression"), + pytest.param("items", _READY, [{"status": "draft"}], False, id="range_expression-other"), + # a value whose status is unknown is not definitely no member + pytest.param("items", _READY, [{"status": "draft"}, {}], True, id="range_expression-unknown"), + ], +) +def test_slot_has_member( + schema: dict[str, Any], slot: str, member: dict[str, Any], values: list | None, valid: bool +) -> None: + """At least one value of the slot must satisfy the member expression; the others need not.""" + schema["slots"][slot]["has_member"] = member + assert verdicts(schema, {} if values is None else {slot: values}) == (valid, valid) + + +_REVIEWED = {"has_member": {"equals_string": "reviewed"}} + + +@pytest.mark.parametrize( + "expression,values,valid", + [ + pytest.param({"all_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, None, False, id="all_of-absent"), + pytest.param({"all_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, ["reviewed"], True, id="all_of"), + pytest.param({"all_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, ["pending"], False, id="all_of-other"), + # has_member decides presence, so it is false, not unknown, for an absent slot + pytest.param({"none_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, None, True, id="none_of-absent"), + pytest.param({"none_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, ["pending"], True, id="none_of-other"), + pytest.param( + {"none_of": [{"slot_conditions": {"tags": _REVIEWED}}]}, ["pending", "reviewed"], False, id="none_of" + ), + pytest.param( + {"any_of": [{"slot_conditions": {"tags": _REVIEWED}}, {"slot_conditions": {"scores": {"required": True}}}]}, + None, + False, + id="any_of-absent", + ), + ], +) +def test_has_member_in_class_expression( + schema: dict[str, Any], expression: dict[str, Any], values: list | None, valid: bool +) -> None: + """In a condition of a class-level expression, ``has_member`` is false for a slot without a value.""" + schema["classes"]["Collection"].update(expression) + assert verdicts(schema, {} if values is None else {"tags": values}) == (valid, valid) + + +@pytest.mark.parametrize( + "items,valid", + [ + pytest.param([{}], True, id="unknown"), + pytest.param([{"status": "draft"}], True, id="other"), + pytest.param([{"status": "draft"}, {"status": "ready"}], False, id="match"), + ], +) +def test_member_under_negation_takes_the_definite_form(schema: dict[str, Any], items: list, valid: bool) -> None: + """Under ``none_of``, a member must definitely satisfy its expression to count.""" + schema["classes"]["Collection"]["none_of"] = [{"slot_conditions": {"items": {"has_member": _READY}}}] + assert verdicts(schema, {"items": items}) == (valid, valid) + + +@pytest.mark.parametrize("scores,valid", [([5], True), ([-1, 11], True), ([11], False), ([-1], False)]) +def test_members_of_two_conditions_may_be_one_value(schema: dict[str, Any], scores: list[int], valid: bool) -> None: + """Each ``has_member`` asks for a value of its own expression, which may or may not be the same value.""" + schema["classes"]["Collection"]["all_of"] = [ + {"slot_conditions": {"scores": {"has_member": {"minimum_value": 0}}}}, + {"slot_conditions": {"scores": {"has_member": {"maximum_value": 10}}}}, + ] + assert verdicts(schema, {"scores": scores}) == (valid, valid) + + +@pytest.mark.parametrize("statuses,valid", [(["draft", "ready"], True), (["draft"], False), ([], False)]) +@pytest.mark.parametrize("in_class_expression", [False, True]) +def test_has_member_on_values_inlined_as_dict( + schema: dict[str, Any], statuses: list[str], valid: bool, in_class_expression: bool +) -> None: + """The members of a slot inlined as a dict are the values of the dict.""" + schema["classes"]["Item"]["slots"].insert(0, "id") + schema["slots"]["id"] = {"identifier": True, "range": "string"} + del schema["slots"]["items"]["inlined_as_list"] + schema["slots"]["items"]["inlined"] = True + if in_class_expression: + schema["classes"]["Collection"]["all_of"] = [{"slot_conditions": {"items": {"has_member": _READY}}}] + else: + schema["slots"]["items"]["has_member"] = _READY + items = {f"i{n}": {"status": status} for n, status in enumerate(statuses)} + assert json_valid(schema, {"items": items}) is valid + data = "ex:c a ex:Collection" + "".join(f"; ex:items ex:{key}" for key in items) + " . " + data += "".join(f'ex:{key} a ex:Item; ex:status "{value["status"]}" . ' for key, value in items.items()) + assert conforms(schema, data) is valid + + +@pytest.mark.parametrize("typed,valid", [(True, True), (False, False)]) +def test_member_range_needs_the_class(schema: dict[str, Any], typed: bool, valid: bool) -> None: + """A member with a class ``range`` must be an instance of that class.""" + schema["classes"]["Special"] = {"is_a": "Item", "slot_usage": {"status": {"required": True}}} + schema["slots"]["items"]["has_member"] = {"range": "Special"} + special = "ex:Special, " if typed else "" + assert conforms(schema, f'ex:c a ex:Collection; ex:items [a {special}ex:Item; ex:status "ready"] .') is valid + assert json_valid(schema, {"items": [{"status": "ready"}]}) + assert not json_valid(schema, {"items": [{}]}) + + +@pytest.mark.parametrize("dict_inlined", [False, True]) +def test_null_has_no_member(schema: dict[str, Any], dict_inlined: bool) -> None: + """A JSON ``null`` is no value, so it has no member, although the generator otherwise allows ``null``.""" + if dict_inlined: + schema["classes"]["Item"]["slots"].insert(0, "id") + schema["slots"]["id"] = {"identifier": True, "range": "string"} + del schema["slots"]["items"]["inlined_as_list"] + schema["slots"]["items"]["inlined"] = True + schema["slots"]["items"]["has_member"] = {} + assert not json_valid(schema, {"items": None}) + del schema["slots"]["items"]["has_member"] + assert json_valid(schema, {"items": None}) + + +@pytest.mark.parametrize("grade,valid", [(3, True), (1, False)]) +def test_member_range_resolves_its_range_expression(schema: dict[str, Any], grade: int, valid: bool) -> None: + """The ``range_expression`` of a member with a ``range`` constrains the slots of that range class.""" + schema["classes"]["Special"] = {"is_a": "Item", "attributes": {"grade": {"range": "integer"}}} + expression = {"slot_conditions": {"grade": {"minimum_value": 2}}} + schema["slots"]["items"]["has_member"] = {"range": "Special", "range_expression": expression} + assert conforms(schema, f"ex:c a ex:Collection; ex:items [a ex:Item, ex:Special; ex:grade {grade}] .") is valid + + +@pytest.mark.parametrize( + "slot_change,member,reason", + [ + pytest.param( + {"multivalued": False}, + {"equals_string": "reviewed"}, + "has_member on slot 'tags', which is not multivalued", + id="single-valued", + ), + pytest.param( + {}, + {"minimum_cardinality": 1}, + "'minimum_cardinality' in the has_member of slot 'tags'", + id="cardinality-of-a-value", + ), + pytest.param( + {}, + {"any_of": [{"equals_string": "reviewed"}]}, + "'any_of' in the has_member of slot 'tags'", + id="slot-operator", + ), + pytest.param( + {"range": "integer"}, + {"equals_string": "5"}, + "equals_string in the has_member of slot 'tags', whose range 'integer' does not hold strings", + id="string-on-integer", + ), + ], +) +def test_untranslatable_has_member_skipped_with_warning( + schema: dict[str, Any], + caplog: pytest.LogCaptureFixture, + slot_change: dict[str, Any], + member: dict[str, Any], + reason: str, +) -> None: + """A ``has_member`` SHACL cannot express is left out of the shapes, with one warning.""" + schema["slots"]["tags"].update(slot_change, has_member=member) + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert conforms(schema, "ex:c a ex:Collection .") + assert [r.getMessage() for r in caplog.records] == [ + f"Slot 'tags': has_member is not translated to SHACL, because it uses {reason} (in the shapes of 'Collection')." + ] + + +def test_untranslatable_has_member_in_class_expression_skips_it( + schema: dict[str, Any], caplog: pytest.LogCaptureFixture +) -> None: + """A class-level expression whose ``has_member`` cannot be translated is skipped as a whole.""" + member = {"any_of": [{"equals_string": "reviewed"}]} + schema["classes"]["Collection"]["all_of"] = [{"slot_conditions": {"tags": {"has_member": member}}}] + with caplog.at_level(logging.WARNING, logger="linkml.generators.shaclgen"): + assert conforms(schema, "ex:c a ex:Collection .") + assert [r.getMessage() for r in caplog.records] == [ + "Class 'Collection': all_of is not translated to SHACL, because it uses " + "'any_of' in the has_member of slot 'tags'." + ] diff --git a/tests/linkml/test_generators/test_jsonschemagen.py b/tests/linkml/test_generators/test_jsonschemagen.py index 1acc8457ea..631d341f16 100644 --- a/tests/linkml/test_generators/test_jsonschemagen.py +++ b/tests/linkml/test_generators/test_jsonschemagen.py @@ -278,6 +278,12 @@ def test_multivalued_element_constraints(subtests, input_path): external_file_test(subtests, input_path("jsonschema_multivalued_element_constraints.yaml")) +def test_multivalued_has_member(subtests, input_path): + """Tests that has_member asks for a value satisfying its expression, which an empty or absent list lacks.""" + + external_file_test(subtests, input_path("jsonschema_multivalued_has_member.yaml")) + + def test_collection_forms(subtests, input_path): """Tests that expanded, compact, and simple dicts can be validated""" diff --git a/tests/linkml/test_generators/test_range_expression.py b/tests/linkml/test_generators/test_range_expression.py index 755626e980..0b620dc1a4 100644 --- a/tests/linkml/test_generators/test_range_expression.py +++ b/tests/linkml/test_generators/test_range_expression.py @@ -20,10 +20,9 @@ from rdflib import Graph, Namespace, URIRef from rdflib.namespace import SH -from linkml.generators.jsonldcontextgen import ContextGenerator from linkml.generators.jsonschemagen import JsonSchemaGenerator from linkml.generators.shaclgen import ShaclGenerator, cli -from linkml_runtime import SchemaView +from tests.linkml.utils import generator_agreement EX = Namespace("https://example.org/context/") PREFIX = f"@prefix ex: <{EX}> . " @@ -66,40 +65,9 @@ def schema() -> dict[str, Any]: """) -def _as_json_ld(sv: SchemaView, obj: dict[str, Any], class_name: str) -> dict[str, Any]: - """*obj* with an ``@type`` on it and on each inlined object: the range of its slot, unless it states one.""" - class_name = obj.get("@type", class_name) - typed = {**obj, "@type": class_name} - for slot in sv.class_induced_slots(class_name): - value = obj.get(slot.name) - if slot.range in sv.all_classes() and value is not None: - values = [_as_json_ld(sv, v, slot.range) for v in (value if isinstance(value, list) else [value])] - typed[slot.name] = values if isinstance(value, list) else values[0] - return typed - - -def _without_types(obj: Any) -> Any: - """*obj* without its ``@type`` keys.""" - if isinstance(obj, dict): - return {k: _without_types(v) for k, v in obj.items() if k != "@type"} - if isinstance(obj, list): - return [_without_types(v) for v in obj] - return obj - - def verdicts(schema: dict[str, Any], instance: dict[str, Any], target: str = "Document") -> tuple[bool, bool]: """Whether the JSON Schema and the SHACL shapes generated for *schema* accept *instance* of *target*.""" - text = yaml.safe_dump(schema) - json_schema = json.loads(JsonSchemaGenerator(text, top_class=target).serialize()) - context = json.loads(ContextGenerator(text).serialize())["@context"] - data = {"@context": context, **_as_json_ld(SchemaView(text), instance, target)} - shapes = ShaclGenerator(text, closed=False).serialize() - conforms, _, _ = validate( - Graph().parse(data=json.dumps(data), format="json-ld"), - shacl_graph=Graph().parse(data=shapes, format="turtle"), - meta_shacl=True, - ) - return Draft201909Validator(json_schema).is_valid(_without_types(instance)), conforms + return generator_agreement.verdicts(schema, instance, target) def conforms(schema: dict[str, Any], data: str, **options: Any) -> bool: diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index 7c365de5b2..fb0231028a 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -6198,15 +6198,15 @@ def test_classes_sharing_a_class_uri_carry_an_inherited_expression_once(): pytest.param( None, {EX_CE.Parent: False, EX_CE.Child: False, EX_CE.GrandChild: False}, - "Class 'Parent': any_of is not translated to SHACL, because it uses 'has_member' in the condition on " + "Class 'Parent': any_of is not translated to SHACL, because it uses 'all_members' in the condition on " "slot 'a' (in the shapes of 'Parent', 'Child', 'GrandChild').", id="untranslatable-everywhere", ), pytest.param( {"a": {"range": "integer"}}, {EX_CE.Parent: True, EX_CE.Child: False, EX_CE.GrandChild: False}, - "Class 'Parent': any_of is not translated to SHACL, because it uses equals_string on slot 'a', whose " - "range 'integer' does not hold strings (in the shapes of 'Child', 'GrandChild').", + "Class 'Parent': any_of is not translated to SHACL, because it uses equals_string in the condition on " + "slot 'a', whose range 'integer' does not hold strings (in the shapes of 'Child', 'GrandChild').", id="untranslatable-in-subclasses", ), ], @@ -6216,7 +6216,7 @@ def test_untranslatable_inherited_class_expression_warned_once(caplog, child_usa shapes where it cannot, and reported once, naming them.""" schema = yaml.safe_load(_INHERITED_CLASS_EXPRESSION_SCHEMA) schema["classes"]["Parent"]["any_of"] = ( - [{"slot_conditions": {"a": {"has_member": {"equals_string": "x"}}}}] + [{"slot_conditions": {"a": {"all_members": {"equals_string": "x"}}}}] if child_usage is None else [{"slot_conditions": {"a": {"equals_string": "x"}}}] ) @@ -6248,7 +6248,7 @@ def test_class_expression_untranslatable_operator_skipped_with_warning(caplog): any_of: - slot_conditions: tags: - has_member: + all_members: equals_string: x - slot_conditions: a: @@ -6264,7 +6264,7 @@ def test_class_expression_untranslatable_operator_skipped_with_warning(caplog): assert (EX_CE.Thing, SH["or"], None) not in g assert any( - "any_of" in rec.message and "has_member" in rec.message and "tags" in rec.message for rec in caplog.records + "any_of" in rec.message and "all_members" in rec.message and "tags" in rec.message for rec in caplog.records ) # the translatable none_of is kept, and enforced shacl_ttl = g.serialize(format="turtle") @@ -6984,14 +6984,14 @@ def test_class_expression_range_with_identifier_is_an_iri(): _UNTRANSLATABLE_MEMBERS = { "untranslatable-second-member": ( - "has_member", + "all_members", """ - slot_conditions: a: required: true - slot_conditions: tags: - has_member: + all_members: equals_string: x""", ), "unknown-condition-range": ( @@ -7005,12 +7005,12 @@ def test_class_expression_range_with_identifier_is_an_iri(): required: true""", ), "untranslatable-nested-member": ( - "has_member", + "all_members", """ - all_of: - slot_conditions: tags: - has_member: + all_members: equals_string: x - slot_conditions: a: diff --git a/tests/linkml/utils/generator_agreement.py b/tests/linkml/utils/generator_agreement.py new file mode 100644 index 0000000000..92a885b0e8 --- /dev/null +++ b/tests/linkml/utils/generator_agreement.py @@ -0,0 +1,54 @@ +"""Validate the same instance against the JSON Schema and the SHACL shapes generated for a schema.""" + +import json +from typing import Any + +import yaml +from jsonschema import Draft201909Validator +from pyshacl import validate +from rdflib import Graph + +from linkml.generators.jsonldcontextgen import ContextGenerator +from linkml.generators.jsonschemagen import JsonSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator +from linkml_runtime import SchemaView + + +def as_json_ld(sv: SchemaView, obj: dict[str, Any], class_name: str) -> dict[str, Any]: + """*obj* with an ``@type`` on it and on each inlined object: the range of its slot, unless it states one.""" + class_name = obj.get("@type", class_name) + typed = {**obj, "@type": class_name} + for slot in sv.class_induced_slots(class_name): + value = obj.get(slot.name) + if slot.range in sv.all_classes() and value is not None: + values = [as_json_ld(sv, v, slot.range) for v in (value if isinstance(value, list) else [value])] + typed[slot.name] = values if isinstance(value, list) else values[0] + return typed + + +def without_types(obj: Any) -> Any: + """*obj* without its ``@type`` keys.""" + if isinstance(obj, dict): + return {k: without_types(v) for k, v in obj.items() if k != "@type"} + if isinstance(obj, list): + return [without_types(v) for v in obj] + return obj + + +def verdicts(schema: dict[str, Any], instance: dict[str, Any], target: str) -> tuple[bool, bool]: + """Whether the JSON Schema and the SHACL shapes generated for *schema* accept *instance* of *target*. + + For SHACL, *instance* is loaded as RDF through the generated JSON-LD context, + and the shapes must pass meta-SHACL. + """ + text = yaml.safe_dump(schema) + json_schema = json.loads(JsonSchemaGenerator(text, top_class=target).serialize()) + context = json.loads(ContextGenerator(text).serialize())["@context"] + data = {"@context": context, **as_json_ld(SchemaView(text), instance, target)} + shapes = ShaclGenerator(text, closed=False).serialize() + conforms, _, _ = validate( + Graph().parse(data=json.dumps(data), format="json-ld"), + shacl_graph=Graph().parse(data=shapes, format="turtle"), + meta_shacl=True, + ) + return Draft201909Validator(json_schema).is_valid(without_types(instance)), conforms From e0085cc441a6786bc48fb6320c6d435075a55e66 Mon Sep 17 00:00:00 2001 From: jdsika Date: Mon, 5 Oct 2026 14:29:45 +0200 Subject: [PATCH 29/30] docs(rdf): model DCMI agent annotations with declared ranges Use the generic metaclass annotation mechanism for creator, contributor, publisher and rights-holder values. Document DCMI rangeIncludes guidance and the distinction between node identifiers and textual identifiers. Add OWL/SHACL contracts for declared nodes, literal URL text, contributor metadata parity and preservation of undeclared OWL annotation values. No additional compiler allowlist or generator option is needed. Signed-off-by: jdsika --- docs/generators/owl.rst | 52 +++++++++++++++ .../test_declared_annotations.py | 64 +++++++++++++++++++ 2 files changed, 116 insertions(+) diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst index 48bab31c11..3ea8810e69 100644 --- a/docs/generators/owl.rst +++ b/docs/generators/owl.rst @@ -159,6 +159,58 @@ References: `LinkML metamodel refinement `DCMI license guidance `__. +DCMI agent metadata +^^^^^^^^^^^^^^^^^^^ + +To name an agent by IRI, declare a ``nodeidentifier`` annotation. To record a +name or other textual identifier, declare ``string``. This works for +``dcterms:creator``, ``dcterms:contributor``, ``dcterms:publisher`` and +``dcterms:rightsHolder`` through the same range conversion as custom properties: + +.. code-block:: yaml + + id: https://example.org/model + name: model + prefixes: + ex: https://example.org/ + linkml: https://w3id.org/linkml/ + dcterms: http://purl.org/dc/terms/ + imports: [linkml:types] + default_prefix: ex + instantiates: [ex:AgentMetadata] + annotations: + creator_label: The Example Team + publisher: ex:team + classes: + AgentMetadata: + attributes: + creator_label: + slot_uri: dcterms:creator + range: string + publisher: + slot_uri: dcterms:publisher + range: nodeidentifier + +The ontology header contains ``dcterms:creator "The Example Team"`` and +``dcterms:publisher ``. A string declaration stays +literal even when its value looks like a URL. For SHACL, put the same +``instantiates`` and ``annotations`` on a class or slot to annotate its shape, +with ``--include-annotations`` enabled. + +The current `DCMI definitions +`__ use +``rangeIncludes: Agent`` for these properties, a suggested range rather than +an ``rdfs:range`` restriction. Creator and rights-holder guidance recommends +URIs while permitting literal identifiers. These definitions do not prescribe +a lexical heuristic for converting LinkML annotation strings into nodes. + +The standard ``contributors`` metamodel field already carries URI-or-CURIE +identifiers; an annotation with the same RDF predicate needs its own declaration. +Do not replace a textual creator with a URL just to trigger conversion, and do +not put a person's name in a node-identifier field. Declaring the representation +in the schema avoids another generator flag or a vocabulary-specific list. + + Enums and PermissibleValues ^^^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/linkml/test_generators/test_declared_annotations.py b/tests/linkml/test_generators/test_declared_annotations.py index 697d60f19c..1222e11905 100644 --- a/tests/linkml/test_generators/test_declared_annotations.py +++ b/tests/linkml/test_generators/test_declared_annotations.py @@ -205,3 +205,67 @@ def test_annotations_do_not_constrain_data() -> None: data = Graph() data.add((URIRef(EX + "instance"), RDF.type, URIRef(EX + "Thing"))) assert validate(data, shacl_graph=graph, meta_shacl=True)[0] + + +@pytest.mark.parametrize("generator", ["owl", "shacl"]) +@pytest.mark.parametrize("property_name", ["creator", "contributor", "publisher", "rightsHolder"]) +@pytest.mark.parametrize( + "range_name,value", + [ + ("nodeidentifier", "ex:team"), + ("nodeidentifier", "https://example.org/team"), + ("string", "The Example Team"), + ("string", "https://example.org/team"), + ], +) +def test_dcmi_agent_annotation_declarations(generator: str, property_name: str, range_name: str, value: str) -> None: + """DCMI agent values follow declarations, including deliberately literal URL text.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/" + schema["classes"]["Metadata"]["attributes"] = { + "agent": {"slot_uri": "dcterms:" + property_name, "range": range_name} + } + schema["classes"]["Thing"].pop("annotations") + metadata = {"instantiates": ["ex:Profile"], "annotations": {"dcterms:" + property_name: value}} + # OWL annotates the ontology header; SHACL annotates the class shape. + if generator == "owl": + schema.update(metadata) + subject = URIRef(EX + "model") + else: + schema["classes"]["Thing"].update(metadata) + subject = URIRef(EX + "Thing") + graph = _graph(yaml.safe_dump(schema), generator, default_language="en") + predicate = URIRef("http://purl.org/dc/terms/" + property_name) + expected = URIRef(EX + "team") if range_name == "nodeidentifier" else Literal(value, lang="en") + assert set(graph.objects(subject, predicate)) == {expected} + + +def test_declared_contributor_matches_metamodel_field() -> None: + """The standard contributors field and an explicitly declared node annotation agree.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/" + schema["classes"]["Metadata"]["attributes"]["contributor"] = { + "slot_uri": "dcterms:contributor", + "range": "nodeidentifier", + } + schema.update( + instantiates=["ex:Profile"], + contributors=["ex:team"], + annotations={"contributor": "ex:team"}, + ) + graph = _graph(yaml.safe_dump(schema), "owl") + assert set(graph.objects(URIRef(EX + "model"), URIRef("http://purl.org/dc/terms/contributor"))) == { + URIRef(EX + "team") + } + + +@pytest.mark.parametrize("property_name", ["creator", "contributor", "publisher", "rightsHolder"]) +def test_undeclared_dcmi_agent_url_stays_literal(property_name: str) -> None: + """A familiar DCMI predicate alone does not authorize OWL node coercion.""" + schema = yaml.safe_load(SCHEMA) + schema["prefixes"]["dcterms"] = "http://purl.org/dc/terms/" + schema["annotations"] = {"dcterms:" + property_name: EX + "team"} + graph = _graph(yaml.safe_dump(schema), "owl") + assert set(graph.objects(URIRef(EX + "model"), URIRef("http://purl.org/dc/terms/" + property_name))) == { + Literal(EX + "team") + } From d75f78248bd5530bfa76ef28b283ba37f98d153c Mon Sep 17 00:00:00 2001 From: jdsika Date: Thu, 8 Oct 2026 16:29:21 +0200 Subject: [PATCH 30/30] fix(integration): reconcile generator features and verify their interactions Share identical SHACL helpers from the rule and class-expression features. Preserve combined pattern/range constraints and type metadata through real generated artifacts, with both RDF label modes. Correct the inherited diff-stability guarantee and normalize integrated test formatting. Signed-off-by: jdsika --- docs/generators/owl.rst | 7 +- .../linkml/src/linkml/generators/shaclgen.py | 86 +---------------- .../test_generator_feature_integration.py | 92 +++++++++++++++++++ tests/linkml/test_generators/test_shaclgen.py | 2 - 4 files changed, 98 insertions(+), 89 deletions(-) create mode 100644 tests/linkml/test_generators/test_generator_feature_integration.py diff --git a/docs/generators/owl.rst b/docs/generators/owl.rst index 3ea8810e69..5a85144e6b 100644 --- a/docs/generators/owl.rst +++ b/docs/generators/owl.rst @@ -514,8 +514,9 @@ RDFC-1.0 numbers blank nodes sequentially (``_:c14n0``, ``_:c14n1``, ...) in canonical order. That is stable for a fixed graph, but inserting a single statement can shift the numbering of every blank node ordered after it, so an unrelated one-line schema edit may rewrite large parts of the file. Pass -``--diff-stable`` to derive each label from the node's own neighbourhood -instead, so that only the blank nodes an edit actually touches are renamed: +``--diff-stable`` to derive labels from blank-node neighbourhoods instead, +reducing label churn across edits. Connected or symmetric structures can still +cause other labels to change; minimal diffs are not guaranteed: .. code:: bash @@ -526,7 +527,7 @@ label differs. ``--diff-stable`` is off by default because turning it on relabels the blank nodes in existing output once. The same ``--diff-stable/--no-diff-stable`` option is available on ``gen-rdf``, -``gen-shacl`` and ``gen-shex``. +``gen-shacl`` and ``gen-shex --format rdf``. Graphs that are not standard RDF -- literal predicates, as produced by ``gen-shacl`` in annotation mode, or relative IRIs such as the metamodel's diff --git a/packages/linkml/src/linkml/generators/shaclgen.py b/packages/linkml/src/linkml/generators/shaclgen.py index ec7d8d12dd..920168f1ac 100644 --- a/packages/linkml/src/linkml/generators/shaclgen.py +++ b/packages/linkml/src/linkml/generators/shaclgen.py @@ -13,8 +13,8 @@ from rdflib.namespace import RDF, RDFS, SH, XSD from linkml._version import __version__ -from linkml.generators.common.class_expression import value_bounds from linkml.generators.common.annotations import declared_annotation +from linkml.generators.common.class_expression import value_bounds from linkml.generators.common.subproperty import get_subproperty_values, is_uri_range, is_xsd_anyuri_range from linkml.generators.shacl.shacl_data_type import ShaclDataType from linkml.generators.shacl.shacl_ifabsent_processor import ShaclIfAbsentProcessor @@ -24,8 +24,8 @@ AnonymousClassExpression, AnonymousSlotExpression, ClassDefinition, - ClassRule, ClassExpression, + ClassRule, Element, ElementName, PresenceEnum, @@ -1090,19 +1090,6 @@ def _skip_rule(self, site: _RuleSite, reason: str) -> None: del self._rule_problems[key] self._warn_rule(site, f"skipped, because {reason}") - # Fields on a slot condition / class expression that carry no constraint - # semantics: they never change which instances satisfy the condition, so - # they are ignored by the operator accounting below. Anything set on a - # condition that is neither here nor explicitly translated by a converter - # makes the rule untranslatable — the converters must SKIP such a rule - # rather than emit a query that silently drops a conjunct (which would - # widen the trigger or narrow the check: a mis-translation, not a skip). - # Derived from the metamodel: the metadata every ``element`` carries, minus - # anything that is a ``slot_expression`` operator. - _NON_OPERATOR_FIELDS = frozenset(f.name for f in fields(Element)) - frozenset( - f.name for f in fields(SlotExpression) - ) - # The lexical space of xsd:boolean and the value each lexical form maps to # (XML Schema 1.1 Part 2 §3.3.2.2, ), # as SPARQL boolean literals. @@ -1115,33 +1102,6 @@ def _skip_rule(self, site: _RuleSite, reason: str) -> None: "and the compositional translation)" ) - @classmethod - def _set_operator_fields( - cls, condition: SlotDefinition | AnonymousSlotExpression | AnonymousClassExpression - ) -> set[str]: - """Return the names of the constraint-bearing fields actually set on a - rule condition or class expression. - - A field counts as *set* when it is not ``None`` and not an empty - collection (SchemaView materialises unset multivalued fields as empty - lists / dicts). Scalars are never judged by truthiness, so legitimate - falsy constraints such as ``minimum_value: 0`` or - ``equals_string: ""`` still count as set. Metadata fields - (:data:`_NON_OPERATOR_FIELDS`) are excluded. - - The converters compare this set against the exact operator set they - translate and skip the rule on any mismatch, so an unrecognised (or - future-metamodel) operator can never be silently dropped. - """ - return { - name - for name, value in vars(condition).items() - if not name.startswith("_") - and name not in cls._NON_OPERATOR_FIELDS - and value is not None - and not (isinstance(value, list | dict) and not value) - } - def _rule_to_sparql(self, site: _RuleSite) -> str | None: """Translate the rule at *site* to a SPARQL SELECT query. @@ -1623,36 +1583,6 @@ def _slot_iri(self, slot: SlotDefinition) -> str: return sv.get_uri(slot, expand=True) return sv.expand_curie(f"{sv.schema.default_prefix}:{underscore(slot.name)}") - def _type_uri(self, r: ElementName | None) -> str | None: - """The expanded datatype IRI of type range *r*, or ``None`` when *r* is not a type. - - Resolved through the induced type, so a type derived with ``typeof`` - inherits the ``uri`` of its ancestor. A built-in type name in a schema - that does not import ``linkml:types`` resolves as the main slot loop - resolves it (:class:`ShaclDataType`). - """ - sv = self.schemaview - if r in sv.all_types(): - return sv.get_uri(sv.induced_type(r), expand=True) - builtin = next((t for t in ShaclDataType if t.linkml_type == r), None) - return str(builtin.uri_ref) if builtin is not None else None - - def _is_string_range(self, r: ElementName | None) -> bool: - """Whether a slot with range *r* holds strings, which ``equals_string`` compares against. - - True for an enum, whose permissible values are rendered as their - ``meaning`` IRI or as a plain literal (as :meth:`_add_enum` renders - them); for a type whose datatype is ``xsd:string``, whose values are - plain literals; and for no range at all, whose values are untyped and - compared as strings, as the JSON Schema generator compares them. A - type with any other datatype, including one derived from ``string`` - (``xsd:anyURI``, ``xsd:token``, ...), holds typed literals or IRIs - that a string literal does not match. - """ - if r is None or r in self.schemaview.all_enums(): - return True - return self._type_uri(r) == str(XSD.string) - def _value_terms(self, site: _RuleSite, slot: SlotDefinition, values: list[str]) -> list[str] | None: """The SPARQL terms of the equals_string(_in) *values* on *slot*, or ``None`` (rule skipped). @@ -1866,18 +1796,6 @@ def _add_class(self, func: Callable, r: ElementName) -> None: range_ref += self.suffix func(SH["node"], URIRef(range_ref)) - def _slot_iri(self, slot: SlotDefinition) -> str: - """The full IRI of *slot*, exactly as ``sh:path`` in the main slot loop renders it. - - An induced slot carries its ``slot_usage`` overrides, so an overridden - ``slot_uri`` yields the same IRI as ``sh:path``; otherwise the query - would use a property the data never uses and never fire. - """ - sv = self.schemaview - if slot.name in sv.element_by_schema_map(): - return sv.get_uri(slot, expand=True) - return sv.expand_curie(f"{sv.schema.default_prefix}:{underscore(slot.name)}") - def _add_range(self, g: Graph, func: Callable, r: ElementName) -> None: """Add the value-type constraint for range *r*: a class, type, enum or built-in datatype.""" sv = self.schemaview diff --git a/tests/linkml/test_generators/test_generator_feature_integration.py b/tests/linkml/test_generators/test_generator_feature_integration.py new file mode 100644 index 0000000000..6c069b145a --- /dev/null +++ b/tests/linkml/test_generators/test_generator_feature_integration.py @@ -0,0 +1,92 @@ +"""Consumer checks for features that overlap on the integration branch.""" + +import pytest +from pyshacl import validate +from rdflib import RDF, Graph, Namespace + +from linkml.generators.owlgen import OwlSchemaGenerator +from linkml.generators.shaclgen import ShaclGenerator + +EX = Namespace("https://example.org/integration/") + + +@pytest.mark.parametrize("diff_stable", [False, True]) +@pytest.mark.parametrize( + "value,details,expected", + [ + ("ex:allowed", 'ex:allowed a ex:Resource ; ex:category "ok" .', True), + ("ex:other", 'ex:other a ex:Resource ; ex:category "ok" .', False), + ("ex:allowed", 'ex:allowed a ex:Resource ; ex:category "wrong" .', False), + ("ex:allowed", "ex:allowed a ex:Resource .", False), + ('"literal"', "", True), + ('"other"', "", False), + ], +) +def test_any_of_combines_pattern_and_range_expression( + diff_stable: bool, value: str, details: str, expected: bool +) -> None: + """Each branch enforces its pattern and nested constraints together after RDF serialization.""" + schema = """ +id: https://example.org/integration +name: integration +prefixes: + ex: https://example.org/integration/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +imports: [linkml:types] +classes: + Thing: + attributes: + value: + any_of: + - range: Resource + pattern: /allowed$ + range_expression: + slot_conditions: + category: {equals_string: ok, required: true} + - range: string + pattern: ^literal$ + Resource: + attributes: + category: {range: string} +""" + shapes = ShaclGenerator(schema, closed=False, diff_stable=diff_stable).serialize() + data = f"@prefix ex: <{EX}> . ex:thing a ex:Thing ; ex:value {value} . {details}" + conforms, _, report = validate( + data_graph=data, + data_graph_format="turtle", + shacl_graph=shapes, + shacl_graph_format="turtle", + meta_shacl=True, + ) + assert conforms is expected, report + + +@pytest.mark.parametrize("diff_stable", [False, True]) +def test_type_keeps_instantiates_and_declared_annotation(diff_stable: bool) -> None: + """A local type retains its metaclass membership and its declared IRI-valued annotation.""" + schema = """ +id: https://example.org/integration +name: integration +prefixes: + ex: https://example.org/integration/ + linkml: https://w3id.org/linkml/ +default_prefix: ex +imports: [linkml:types] +classes: + Metadata: + class_uri: ex:Metadata + attributes: + reference: + slot_uri: ex:reference + range: nodeidentifier +types: + Label: + typeof: string + instantiates: [ex:Metadata] + annotations: + reference: ex:target +""" + graph = Graph().parse(data=OwlSchemaGenerator(schema, diff_stable=diff_stable).serialize(), format="turtle") + assert (EX.Label, RDF.type, EX.Metadata) in graph + assert (EX.Label, EX.reference, EX.target) in graph diff --git a/tests/linkml/test_generators/test_shaclgen.py b/tests/linkml/test_generators/test_shaclgen.py index fb0231028a..29ca466421 100644 --- a/tests/linkml/test_generators/test_shaclgen.py +++ b/tests/linkml/test_generators/test_shaclgen.py @@ -3923,7 +3923,6 @@ def test_rule_shared_shape_reports_once(): # ==================================================================== - _ENUM_NARROWING_SCHEMA_YAML = """ id: https://example.org/enum-narrowing name: enum_narrowing_rules @@ -4464,7 +4463,6 @@ def test_rule_equals_string_special_chars_escaped(): # ==================================================================== - @pytest.mark.parametrize( "extra,reason", [