linkml 1.8.4__py3-none-any.whl → 1.8.6__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. linkml/cli/main.py +2 -0
  2. linkml/generators/common/build.py +1 -2
  3. linkml/generators/common/ifabsent_processor.py +98 -21
  4. linkml/generators/common/lifecycle.py +18 -2
  5. linkml/generators/common/naming.py +106 -0
  6. linkml/generators/dbmlgen.py +173 -0
  7. linkml/generators/docgen.py +16 -7
  8. linkml/generators/erdiagramgen.py +1 -0
  9. linkml/generators/graphqlgen.py +34 -2
  10. linkml/generators/jsonldcontextgen.py +7 -1
  11. linkml/generators/jsonschemagen.py +73 -53
  12. linkml/generators/linkmlgen.py +13 -1
  13. linkml/generators/owlgen.py +11 -1
  14. linkml/generators/plantumlgen.py +17 -10
  15. linkml/generators/pydanticgen/array.py +21 -61
  16. linkml/generators/pydanticgen/template.py +12 -1
  17. linkml/generators/pydanticgen/templates/attribute.py.jinja +1 -1
  18. linkml/generators/python/python_ifabsent_processor.py +1 -1
  19. linkml/generators/pythongen.py +123 -21
  20. linkml/generators/shaclgen.py +16 -5
  21. linkml/generators/typescriptgen.py +3 -1
  22. linkml/linter/rules.py +3 -1
  23. linkml/utils/converter.py +17 -0
  24. linkml/utils/deprecation.py +10 -0
  25. linkml/utils/helpers.py +65 -0
  26. linkml/utils/validation.py +2 -1
  27. linkml/validator/__init__.py +2 -2
  28. linkml/validator/plugins/jsonschema_validation_plugin.py +1 -0
  29. linkml/validator/report.py +4 -1
  30. linkml/validator/validator.py +4 -4
  31. linkml/validators/jsonschemavalidator.py +10 -0
  32. linkml/validators/sparqlvalidator.py +7 -0
  33. linkml/workspaces/example_runner.py +20 -1
  34. {linkml-1.8.4.dist-info → linkml-1.8.6.dist-info}/METADATA +2 -2
  35. {linkml-1.8.4.dist-info → linkml-1.8.6.dist-info}/RECORD +38 -36
  36. {linkml-1.8.4.dist-info → linkml-1.8.6.dist-info}/entry_points.txt +1 -0
  37. {linkml-1.8.4.dist-info → linkml-1.8.6.dist-info}/LICENSE +0 -0
  38. {linkml-1.8.4.dist-info → linkml-1.8.6.dist-info}/WHEEL +0 -0
linkml/cli/main.py CHANGED
@@ -8,6 +8,7 @@ import click
8
8
 
9
9
  from linkml._version import __version__
10
10
  from linkml.generators.csvgen import cli as gen_csv
11
+ from linkml.generators.dbmlgen import cli as gen_dbml
11
12
  from linkml.generators.docgen import cli as gen_doc
12
13
  from linkml.generators.dotgen import cli as gen_graphviz
13
14
  from linkml.generators.erdiagramgen import cli as gen_erdiagram
@@ -125,6 +126,7 @@ generate.add_command(gen_project, name="project")
125
126
  generate.add_command(gen_excel, name="excel")
126
127
  generate.add_command(gen_sssom, name="sssom")
127
128
  generate.add_command(gen_linkml, name="linkml")
129
+ generate.add_command(gen_dbml, name="dbml")
128
130
 
129
131
  # Dev helpers
130
132
  dev.add_command(run_tutorial, name="tutorial")
@@ -5,7 +5,6 @@ Models for intermediate build results
5
5
  """
6
6
 
7
7
  import dataclasses
8
- from abc import abstractmethod
9
8
  from typing import Any, TypeVar
10
9
 
11
10
  try:
@@ -57,11 +56,11 @@ class BuildResult(BaseModel):
57
56
 
58
57
  model_config = ConfigDict(arbitrary_types_allowed=True)
59
58
 
60
- @abstractmethod
61
59
  def merge(self, other: T) -> T:
62
60
  """
63
61
  Build results should have some means of merging results of a like kind
64
62
  """
63
+ raise NotImplementedError("This build result doesn't know how to merge!")
65
64
 
66
65
 
67
66
  class SchemaResult(BuildResult):
@@ -1,7 +1,13 @@
1
1
  import abc
2
2
  import re
3
+ import sys
3
4
  from abc import ABC
4
- from typing import Any, Optional
5
+ from typing import Any, Optional, Type, Union
6
+
7
+ if sys.version_info < (3, 10):
8
+ from typing_extensions import TypeAlias
9
+ else:
10
+ from typing import TypeAlias
5
11
 
6
12
  from linkml_runtime import SchemaView
7
13
  from linkml_runtime.linkml_model import (
@@ -18,6 +24,7 @@ from linkml_runtime.linkml_model import (
18
24
  String,
19
25
  Time,
20
26
  Uri,
27
+ types,
21
28
  )
22
29
  from linkml_runtime.linkml_model.types import (
23
30
  Curie,
@@ -31,6 +38,34 @@ from linkml_runtime.linkml_model.types import (
31
38
  Uriorcurie,
32
39
  )
33
40
 
41
+ TYPES_TYPE: TypeAlias = Union[
42
+ Type[Boolean],
43
+ Type[Curie],
44
+ Type[Date],
45
+ Type[DateOrDatetime],
46
+ Type[Datetime],
47
+ Type[Decimal],
48
+ Type[Double],
49
+ Type[Float],
50
+ Type[Integer],
51
+ Type[Jsonpath],
52
+ Type[Jsonpointer],
53
+ Type[Ncname],
54
+ Type[Nodeidentifier],
55
+ Type[Objectidentifier],
56
+ Type[Sparqlpath],
57
+ Type[String],
58
+ Type[Time],
59
+ Type[Uri],
60
+ Type[Uriorcurie],
61
+ ]
62
+
63
+ TYPES = [
64
+ t
65
+ for t in types.__dict__.values()
66
+ if isinstance(t, type) and t.__module__ == types.__name__ and hasattr(t, "type_name")
67
+ ]
68
+
34
69
 
35
70
  class IfAbsentProcessor(ABC):
36
71
  """
@@ -39,7 +74,7 @@ class IfAbsentProcessor(ABC):
39
74
  See `<https://w3id.org/linkml/ifabsent>`_.
40
75
  """
41
76
 
42
- ifabsent_regex = re.compile("""(?:(?P<type>\w+)\()?[\"\']?(?P<default_value>[^\(\)\"\')]*)[\"\']?\)?""")
77
+ ifabsent_regex = re.compile(r"""(?:(?P<type>\w+)\()?[\"\']?(?P<default_value>[^\(\)\"\')]*)[\"\']?\)?""")
43
78
 
44
79
  def __init__(self, schema_view: SchemaView):
45
80
  self.schema_view = schema_view
@@ -61,10 +96,12 @@ class IfAbsentProcessor(ABC):
61
96
  if mapped:
62
97
  return custom_default_value
63
98
 
64
- if slot.range == String.type_name:
99
+ base_type = self._base_type(slot.range)
100
+
101
+ if base_type is String:
65
102
  return self.map_string_default_value(ifabsent_default_value, slot, cls)
66
103
 
67
- if slot.range == Boolean.type_name:
104
+ if base_type is Boolean:
68
105
  if re.match(r"^[Tt]rue$", ifabsent_default_value):
69
106
  return self.map_boolean_true_default_value(slot, cls)
70
107
  elif re.match(r"^[Ff]alse$", ifabsent_default_value):
@@ -75,19 +112,19 @@ class IfAbsentProcessor(ABC):
75
112
  f"value"
76
113
  )
77
114
 
78
- if slot.range == Integer.type_name:
115
+ if base_type is Integer:
79
116
  return self.map_integer_default_value(ifabsent_default_value, slot, cls)
80
117
 
81
- if slot.range == Float.type_name:
118
+ if base_type is Float:
82
119
  return self.map_float_default_value(ifabsent_default_value, slot, cls)
83
120
 
84
- if slot.range == Double.type_name:
121
+ if base_type is Double:
85
122
  return self.map_double_default_value(ifabsent_default_value, slot, cls)
86
123
 
87
- if slot.range == Decimal.type_name:
124
+ if base_type is Decimal:
88
125
  return self.map_decimal_default_value(ifabsent_default_value, slot, cls)
89
126
 
90
- if slot.range == Time.type_name:
127
+ if base_type is Time:
91
128
  match = re.match(r"^(\d{2}):(\d{2}):(\d{2}).*$", ifabsent_default_value)
92
129
  if match:
93
130
  return self.map_time_default_value(match[1], match[2], match[3], slot, cls)
@@ -97,7 +134,7 @@ class IfAbsentProcessor(ABC):
97
134
  )
98
135
 
99
136
  # TODO manage timezones and offsets
100
- if slot.range == Date.type_name:
137
+ if base_type is Date:
101
138
  match = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", ifabsent_default_value)
102
139
  if match:
103
140
  return self.map_date_default_value(match[1], match[2], match[3], slot, cls)
@@ -107,7 +144,7 @@ class IfAbsentProcessor(ABC):
107
144
  )
108
145
 
109
146
  # TODO manage timezones and offsets
110
- if slot.range == Datetime.type_name:
147
+ if base_type is Datetime:
111
148
  match = re.match(r"^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}).*$", ifabsent_default_value)
112
149
  if match:
113
150
  return self.map_datetime_default_value(
@@ -120,7 +157,7 @@ class IfAbsentProcessor(ABC):
120
157
  )
121
158
 
122
159
  # TODO manage timezones and offsets
123
- if slot.range == DateOrDatetime.type_name:
160
+ if base_type is DateOrDatetime:
124
161
  match = re.match(r"^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2}))?.*$", ifabsent_default_value)
125
162
  if match and (match[4] is None or match[5] is None or match[6] is None):
126
163
  return self.map_date_default_value(match[1], match[2], match[3], slot, cls)
@@ -134,31 +171,31 @@ class IfAbsentProcessor(ABC):
134
171
  f"datetime value"
135
172
  )
136
173
 
137
- if slot.range == Uri.type_name:
174
+ if base_type is Uri:
138
175
  return self.map_uri_default_value(ifabsent_default_value, slot, cls)
139
176
 
140
- if slot.range == Curie.type_name:
177
+ if base_type is Curie:
141
178
  return self.map_curie_default_value(ifabsent_default_value, slot, cls)
142
179
 
143
- if slot.range == Uriorcurie.type_name:
180
+ if base_type is Uriorcurie:
144
181
  return self.map_uri_or_curie_default_value(ifabsent_default_value, slot, cls)
145
182
 
146
- if slot.range == Ncname.type_name:
183
+ if base_type is Ncname:
147
184
  return self.map_nc_name_default_value(ifabsent_default_value, slot, cls)
148
185
 
149
- if slot.range == Objectidentifier.type_name:
186
+ if base_type is Objectidentifier:
150
187
  return self.map_object_identifier_default_value(ifabsent_default_value, slot, cls)
151
188
 
152
- if slot.range == Nodeidentifier.type_name:
189
+ if base_type is Nodeidentifier:
153
190
  return self.map_node_identifier_default_value(ifabsent_default_value, slot, cls)
154
191
 
155
- if slot.range == Jsonpointer.type_name:
192
+ if base_type is Jsonpointer:
156
193
  return self.map_json_pointer_default_value(ifabsent_default_value, slot, cls)
157
194
 
158
- if slot.range == Jsonpath.type_name:
195
+ if base_type is Jsonpath:
159
196
  return self.map_json_path_default_value(ifabsent_default_value, slot, cls)
160
197
 
161
- if slot.range == Sparqlpath.type_name:
198
+ if base_type is Sparqlpath:
162
199
  return self.map_sparql_path_default_value(ifabsent_default_value, slot, cls)
163
200
 
164
201
  # -----------------------
@@ -173,6 +210,46 @@ class IfAbsentProcessor(ABC):
173
210
 
174
211
  raise ValueError(f"The ifabsent value `{slot.ifabsent}` of the `{slot.name}` slot could not be processed")
175
212
 
213
+ def _base_type(self, range_: str) -> Optional[TYPES_TYPE]:
214
+ """
215
+ Find the linkml base type that corresponds to either a matching type_name or custom type
216
+
217
+ First check for an explicit match of the range == TypeDefinition.type_name
218
+ Then check for explicit inheritance via typeof
219
+ Finally check for implicit matching via matching base
220
+
221
+ Don't raise here, just return None in case another method of resolution like enums are
222
+ available
223
+ """
224
+ # first check for matching type using type_name - ie. range is already a base type
225
+
226
+ for typ in TYPES:
227
+ if range_ == typ.type_name:
228
+ return typ
229
+
230
+ # if we're not a type, return None to indicate that, e.g. if an enum's permissible_value
231
+ if range_ not in self.schema_view.all_types(imports=True):
232
+ return
233
+
234
+ # then check explicit descendents of types
235
+ # base types do not inherit from one another, so the last ancestor is always a base type
236
+ # if it is inheriting from a base type
237
+ ancestor = self.schema_view.type_ancestors(range_)[-1]
238
+ for typ in TYPES:
239
+ if ancestor == typ.type_name:
240
+ return typ
241
+
242
+ # finally check if we have a matching base
243
+ induced_typ = self.schema_view.induced_type(range_)
244
+ if induced_typ.repr is None and induced_typ.base is None:
245
+ return None
246
+ for typ in TYPES:
247
+ # types always inherit from a single type, and that type is their base
248
+ # our range can match it with repr or base
249
+ typ_base = typ.__mro__[1].__name__
250
+ if typ_base == induced_typ.base:
251
+ return typ
252
+
176
253
  @abc.abstractmethod
177
254
  def map_custom_default_values(self, default_value: str, slot: SlotDefinition, cls: ClassDefinition) -> (bool, str):
178
255
  """
@@ -13,7 +13,7 @@ from linkml_runtime.linkml_model.meta import (
13
13
  TypeDefinition,
14
14
  )
15
15
 
16
- from linkml.generators.common.build import ClassResult, RangeResult, SchemaResult, SlotResult, TypeResult
16
+ from linkml.generators.common.build import ClassResult, EnumResult, RangeResult, SchemaResult, SlotResult, TypeResult
17
17
  from linkml.generators.common.template import TemplateModel
18
18
 
19
19
  TSchema = TypeVar("TSchema", bound=SchemaResult)
@@ -21,7 +21,7 @@ TClass = TypeVar("TClass", bound=ClassResult)
21
21
  TSlot = TypeVar("TSlot", bound=SlotResult)
22
22
  TRange = TypeVar("TRange", bound=RangeResult)
23
23
  TType = TypeVar("TType", bound=TypeResult)
24
- TEnum = TypeVar("TEnum", bound=EnumDefinition)
24
+ TEnum = TypeVar("TEnum", bound=EnumResult)
25
25
  TTemplate = TypeVar("TTemplate", bound=TemplateModel)
26
26
 
27
27
 
@@ -93,6 +93,22 @@ class LifecycleMixin:
93
93
  def after_generate_slots(self, slot: Iterable[TSlot], sv: SchemaView) -> Iterable[TSlot]:
94
94
  return slot
95
95
 
96
+ def before_generate_class_slot(self, slot: SlotDefinition, cls: ClassDefinition, sv: SchemaView) -> SlotDefinition:
97
+ return slot
98
+
99
+ def after_generate_class_slot(self, slot: TSlot, cls: ClassDefinition, sv: SchemaView) -> TSlot:
100
+ return slot
101
+
102
+ def before_generate_class_slots(
103
+ self, slot: Iterable[SlotDefinition], cls: ClassDefinition, sv: SchemaView
104
+ ) -> Iterable[SlotDefinition]:
105
+ return slot
106
+
107
+ def after_generate_class_slots(
108
+ self, slot: Iterable[TSlot], cls: ClassDefinition, sv: SchemaView
109
+ ) -> Iterable[TSlot]:
110
+ return slot
111
+
96
112
  def before_generate_type(self, typ: TypeDefinition, sv: SchemaView) -> TypeDefinition:
97
113
  return typ
98
114
 
@@ -0,0 +1,106 @@
1
+ import logging
2
+ import re
3
+ import unicodedata
4
+ from enum import Enum
5
+
6
+
7
+ class NamingProfiles(str, Enum):
8
+ # GraphQL naming profile ensures compatibility with the GraphQL specification
9
+ # WRT names: https://spec.graphql.org/October2021/#Name
10
+ graphql = "graphql"
11
+
12
+
13
+ class NameCompatibility(object):
14
+ """Make a name compatible to the given profile"""
15
+
16
+ # heading double underscores and digit reserved to names starting with a digit
17
+ re_reserved_heading_digit = re.compile("^__[0-9]")
18
+ # valid name between double underscores is reserved for unicode name transformations
19
+ re_reserved_unicode_name_transformation = re.compile("__[0-9a-zA-Z][0-9a-zA-Z_]*__")
20
+ # something like '__U_xxxx_' is reserved for unicode code transformations
21
+ re_reserved_unicode_code_transformation = re.compile("__U_[0-9a-eA-E]{4}_")
22
+ # strings starting with a digit
23
+ re_heading_digit = re.compile("^[0-9]")
24
+ # character that is neither alphanumeric nor underscore
25
+ re_no_alphanum_underscore = re.compile("[^0-9a-zA-Z_]")
26
+
27
+ def __init__(self, profile: NamingProfiles, do_not_fix: bool = False):
28
+ """Specify the naming policy on instantiation"""
29
+ self.profile = profile
30
+ self.do_not_fix = do_not_fix
31
+
32
+ def _no_heading_digits(self, input: str) -> str:
33
+ """Ensure name does not start with a heading digit"""
34
+ output = input
35
+ if self.re_heading_digit.match(input):
36
+ if self.do_not_fix:
37
+ raise ValueError(f"Name '{input}' starts with digit (illegal GraphQL) and will not be fixed!")
38
+ else:
39
+ logging.warning(
40
+ f"Name '{input}' starts with digit (illegal GraphQL), "
41
+ + f"it has been prefixed with '__', resulting in {output}"
42
+ )
43
+ output = f"__{input}"
44
+ return output
45
+
46
+ def _transform_char(self, char: str) -> str:
47
+ """Transform unsupported characters"""
48
+ if len(char) != 1:
49
+ raise Exception(f"Single character expected, but got '{char}'!!")
50
+ # replace whitespaces with underscores
51
+ # the transformation cannot be inverted, but is a well-established
52
+ # and expected transformation
53
+ if char == " ":
54
+ return "_"
55
+ # try to use names for ASCII characters
56
+ if ord(char) < 128:
57
+ try:
58
+ # unicodedata.lookup should be able to invert the transformation
59
+ return f"__{unicodedata.name(char).replace(' ', '_').replace('-', '_')}__"
60
+ except ValueError:
61
+ pass
62
+ # fallback to code-transformation if none of the previous has worked
63
+ return f"__U_{ord(char):04X}_"
64
+
65
+ def _only_alphanum_underscore(self, input: str) -> str:
66
+ """Ensure name does not contain any unsupported characters"""
67
+ output = input
68
+ # with re.split and re.findall we get in the same order and separated in two arrays
69
+ # the substrings between special characters and the special characters
70
+ no_alphanum_underscore_match = self.re_no_alphanum_underscore.findall(input)
71
+ if no_alphanum_underscore_match:
72
+ if self.do_not_fix:
73
+ raise ValueError(f"Name '{input}' contains a character illegal in GraphQL and will not be fixed!")
74
+ else:
75
+ logging.warning(
76
+ f"Name '{input}' contains a character illegal in GraphQL, "
77
+ + f"the resulting encoded name is {output}"
78
+ )
79
+ to_keep = self.re_no_alphanum_underscore.split(input)
80
+ # first comes first substring to keep
81
+ output = to_keep[0]
82
+ # each char replacement is followed by the next substring to keep
83
+ for offset in range(0, len(to_keep) - 1):
84
+ output = output + self._transform_char(no_alphanum_underscore_match[offset])
85
+ output = output + to_keep[offset + 1]
86
+ return output
87
+
88
+ def _graphql_compatibility(self, input: str) -> str:
89
+ """Ensure name compatibility with GraphQL name policies"""
90
+ # as of now, some (hopefully) very rare patterns are reserved to mark transformations
91
+ if self.re_reserved_heading_digit.match(input):
92
+ raise NotImplementedError("Names starting with a double underscore followed by a digit are not supported!!")
93
+ if self.re_reserved_unicode_name_transformation.match(input):
94
+ raise NotImplementedError("Names containing valid names between double underscores are not supported!!")
95
+ if self.re_reserved_unicode_code_transformation.match(input):
96
+ raise NotImplementedError("Names containing strings like '__U_xxxx_' are not supported!!")
97
+ # apply transformation
98
+ output = input
99
+ output = self._no_heading_digits(output)
100
+ output = self._only_alphanum_underscore(output)
101
+ return output
102
+
103
+ def compatible(self, input: str) -> str:
104
+ """Make given name compatible with the given naming policy."""
105
+ if self.profile == "graphql":
106
+ return self._graphql_compatibility(input)
@@ -0,0 +1,173 @@
1
+ import logging
2
+
3
+ import click
4
+ from linkml_runtime.utils.formatutils import camelcase, underscore
5
+ from linkml_runtime.utils.schemaview import SchemaView
6
+
7
+ from linkml.utils.generator import Generator
8
+
9
+
10
+ def _map_range_to_dbml_type(range_name: str) -> str:
11
+ """
12
+ Map LinkML range types to DBML types.
13
+
14
+ :param range_name: LinkML range name
15
+ :return: Corresponding DBML type
16
+ """
17
+ type_mapping = {
18
+ "string": "varchar",
19
+ "integer": "int",
20
+ "float": "float",
21
+ "boolean": "boolean",
22
+ "date": "date",
23
+ "datetime": "datetime",
24
+ }
25
+ return type_mapping.get(range_name, "varchar") # Default to varchar
26
+
27
+
28
+ class DBMLGenerator(Generator):
29
+ """
30
+ A generator for converting a LinkML schema into DBML (Database Markup Language).
31
+ """
32
+
33
+ generatorname = "dbmlgen"
34
+ generatorversion = "0.2.0"
35
+ valid_formats = ["dbml"]
36
+
37
+ def __post_init__(self) -> None:
38
+ super().__post_init__()
39
+ self.logger = logging.getLogger(__name__)
40
+ self.schemaview = SchemaView(self.schema)
41
+
42
+ def serialize(self) -> str:
43
+ """
44
+ Generate DBML representation of the LinkML schema.
45
+
46
+ :return: DBML as a string
47
+ """
48
+ dbml_lines = [
49
+ "// DBML generated from LinkML schema\n",
50
+ f"Project {{\n name: '{self.schemaview.schema.name}'\n}}\n",
51
+ ]
52
+
53
+ for class_name, class_def in self.schemaview.all_classes().items():
54
+ dbml_lines.append(self._generate_table(class_name, class_def))
55
+
56
+ # Generate relationships if applicable
57
+ relationships = self._generate_relationships()
58
+ if relationships:
59
+ dbml_lines.append(relationships)
60
+
61
+ return "\n".join(dbml_lines)
62
+
63
+ def _generate_table(self, class_name: str, class_def) -> str:
64
+ """
65
+ Generate the DBML for a single class (table).
66
+
67
+ :param class_name: Name of the class
68
+ :param class_def: ClassDefinition object
69
+ :return: DBML representation of the class
70
+ """
71
+ dbml = [f"Table {camelcase(class_name)} {{"]
72
+
73
+ for slot_name in self.schemaview.class_induced_slots(class_name):
74
+ slot = self.schemaview.get_slot(slot_name.name)
75
+ dbml.append(self._generate_column(slot))
76
+
77
+ dbml.append("}\n")
78
+ return "\n".join(dbml)
79
+
80
+ def _generate_column(self, slot) -> str:
81
+ """
82
+ Generate the DBML for a single slot (column).
83
+
84
+ :param slot: SlotDefinition object
85
+ :return: DBML representation of the column
86
+ """
87
+ column_name = slot.name
88
+ data_type = _map_range_to_dbml_type(slot.range or "string")
89
+ constraints = []
90
+ constraints_str = ""
91
+
92
+ if slot.required:
93
+ constraints.append("not null")
94
+ if slot.identifier:
95
+ constraints.append("primary key")
96
+
97
+ if constraints:
98
+ constraints_str = f"{', '.join(constraints)}"
99
+ constraints_str = "[" + constraints_str + "]"
100
+
101
+ return f" {underscore(column_name)} {data_type} {constraints_str}".strip()
102
+
103
+ def _generate_relationships(self) -> str:
104
+ """
105
+ Generate DBML relationships based on slot ranges referencing other classes.
106
+
107
+ :return: DBML representation of relationships
108
+ """
109
+ relationships = []
110
+ for class_name, class_def in self.schemaview.all_classes().items():
111
+ for slot_name in self.schemaview.class_induced_slots(class_name):
112
+ slot = self.schemaview.get_slot(slot_name.name)
113
+
114
+ # Check if the slot references another class
115
+ if slot.range in self.schemaview.all_classes():
116
+
117
+ # Find the identifier slot of the referenced class
118
+ identifier_slot_name = next(
119
+ (
120
+ slot_name.name
121
+ for slot_name in self.schemaview.class_induced_slots(slot.range)
122
+ if self.schemaview.get_slot(slot_name.name).identifier
123
+ ),
124
+ None,
125
+ )
126
+
127
+ if identifier_slot_name is None:
128
+ raise ValueError(f"Referenced class '{slot.range}' does not have an identifier slot.")
129
+
130
+ # Generate the DBML relationship
131
+ relationships.append(
132
+ f"Ref: {camelcase(class_name)}.{underscore(slot.name)} > "
133
+ f"{camelcase(slot.range)}.{underscore(identifier_slot_name)}"
134
+ )
135
+ return "\n".join(relationships)
136
+
137
+
138
+ # CLI Definition
139
+ @click.command()
140
+ @click.option(
141
+ "--schema",
142
+ "-s",
143
+ required=True,
144
+ type=click.Path(exists=True, dir_okay=False, file_okay=True),
145
+ help="Path to the LinkML schema YAML file",
146
+ )
147
+ @click.option(
148
+ "--output",
149
+ "-o",
150
+ required=False,
151
+ type=click.Path(dir_okay=False, writable=True),
152
+ help="Path to save the generated DBML file. If not specified, DBML will be printed to stdout.",
153
+ )
154
+ def cli(schema, output):
155
+ """
156
+ CLI for LinkML to DBML generator.
157
+ """
158
+ generator = DBMLGenerator(schema)
159
+
160
+ # Generate the DBML
161
+ dbml_output = generator.serialize()
162
+
163
+ # Save to file or print to stdout
164
+ if output:
165
+ with open(output, "w", encoding="utf-8") as f:
166
+ f.write(dbml_output)
167
+ click.echo(f"DBML has been saved to {output}")
168
+ else:
169
+ click.echo(dbml_output)
170
+
171
+
172
+ if __name__ == "__main__":
173
+ cli()
@@ -164,6 +164,7 @@ class DocGenerator(Generator):
164
164
  use_slot_uris: bool = False
165
165
  use_class_uris: bool = False
166
166
  hierarchical_class_view: bool = False
167
+ render_imports: bool = False
167
168
 
168
169
  def __post_init__(self):
169
170
  dialect = self.dialect
@@ -759,7 +760,7 @@ class DocGenerator(Generator):
759
760
  Ensures rank is non-null
760
761
  :return: iterator
761
762
  """
762
- elts = self.schemaview.all_classes(imports=self.mergeimports).values()
763
+ elts = self.schemaview.all_classes(imports=self.render_imports).values()
763
764
  _ensure_ranked(elts)
764
765
  for e in elts:
765
766
  yield e
@@ -771,7 +772,7 @@ class DocGenerator(Generator):
771
772
  Ensures rank is non-null
772
773
  :return: iterator
773
774
  """
774
- elts = self.schemaview.all_slots(imports=self.mergeimports).values()
775
+ elts = self.schemaview.all_slots(imports=self.render_imports).values()
775
776
  _ensure_ranked(elts)
776
777
  for e in elts:
777
778
  yield e
@@ -783,7 +784,7 @@ class DocGenerator(Generator):
783
784
  Ensures rank is non-null
784
785
  :return: iterator
785
786
  """
786
- elts = self.schemaview.all_types(imports=self.mergeimports).values()
787
+ elts = self.schemaview.all_types(imports=self.render_imports).values()
787
788
  _ensure_ranked(elts)
788
789
  for e in elts:
789
790
  yield e
@@ -798,7 +799,7 @@ class DocGenerator(Generator):
798
799
  Ensures rank is non-null
799
800
  :return: iterator
800
801
  """
801
- elts = self.schemaview.all_enums(imports=self.mergeimports).values()
802
+ elts = self.schemaview.all_enums(imports=self.render_imports).values()
802
803
  _ensure_ranked(elts)
803
804
  for e in elts:
804
805
  yield e
@@ -810,7 +811,7 @@ class DocGenerator(Generator):
810
811
  Ensures rank is non-null
811
812
  :return: iterator
812
813
  """
813
- elts = self.schemaview.all_subsets(imports=self.mergeimports).values()
814
+ elts = self.schemaview.all_subsets(imports=self.render_imports).values()
814
815
  _ensure_ranked(elts)
815
816
  for e in elts:
816
817
  yield e
@@ -834,7 +835,7 @@ class DocGenerator(Generator):
834
835
  :return: tuples (depth: int, cls: ClassDefinitionName)
835
836
  """
836
837
  sv = self.schemaview
837
- roots = sv.class_roots(mixins=False, imports=self.mergeimports)
838
+ roots = sv.class_roots(mixins=False, imports=self.render_imports)
838
839
 
839
840
  # by default the classes are sorted alphabetically
840
841
  roots = sorted(roots, key=str.casefold, reverse=True)
@@ -848,7 +849,7 @@ class DocGenerator(Generator):
848
849
  depth, class_name = stack.pop()
849
850
  yield depth, class_name
850
851
  children = sorted(
851
- sv.class_children(class_name=class_name, mixins=False, imports=self.mergeimports),
852
+ sv.class_children(class_name=class_name, mixins=False, imports=self.render_imports),
852
853
  key=str.casefold,
853
854
  reverse=True,
854
855
  )
@@ -1008,6 +1009,12 @@ class DocGenerator(Generator):
1008
1009
  default=True,
1009
1010
  help="Render class table on index page in a hierarchically indented view",
1010
1011
  )
1012
+ @click.option(
1013
+ "--render-imports/--no-render-imports",
1014
+ default=False,
1015
+ show_default=True,
1016
+ help="Render also the documentation of elements from imported schemas",
1017
+ )
1011
1018
  @click.option(
1012
1019
  "--example-directory",
1013
1020
  help="Folder in which example files are found. These are used to make inline examples",
@@ -1037,6 +1044,7 @@ def cli(
1037
1044
  use_class_uris,
1038
1045
  hierarchical_class_view,
1039
1046
  subfolder_type_separation,
1047
+ render_imports,
1040
1048
  **args,
1041
1049
  ):
1042
1050
  """Generate documentation folder from a LinkML YAML schema
@@ -1068,6 +1076,7 @@ def cli(
1068
1076
  hierarchical_class_view=hierarchical_class_view,
1069
1077
  index_name=index_name,
1070
1078
  subfolder_type_separation=subfolder_type_separation,
1079
+ render_imports=render_imports,
1071
1080
  **args,
1072
1081
  )
1073
1082
  print(gen.serialize())
@@ -308,6 +308,7 @@ class ERDiagramGenerator(Generator):
308
308
  default=False,
309
309
  help="If True, follow references even if not inlined",
310
310
  )
311
+ @click.option("--format", "-f", default="markdown", type=click.Choice(ERDiagramGenerator.valid_formats))
311
312
  @click.option("--max-hops", default=None, type=click.INT, help="Maximum number of hops")
312
313
  @click.option("--classes", "-c", multiple=True, help="List of classes to serialize")
313
314
  @click.option("--include-upstream", is_flag=True, help="Include upstream classes")