linkml 1.8.1__py3-none-any.whl → 1.8.2__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 (70) hide show
  1. linkml/cli/__init__.py +0 -0
  2. linkml/cli/__main__.py +4 -0
  3. linkml/cli/main.py +126 -0
  4. linkml/generators/common/build.py +105 -0
  5. linkml/generators/common/lifecycle.py +124 -0
  6. linkml/generators/common/template.py +89 -0
  7. linkml/generators/csvgen.py +1 -1
  8. linkml/generators/docgen/slot.md.jinja2 +4 -0
  9. linkml/generators/docgen.py +1 -1
  10. linkml/generators/dotgen.py +1 -1
  11. linkml/generators/erdiagramgen.py +1 -1
  12. linkml/generators/excelgen.py +1 -1
  13. linkml/generators/golanggen.py +1 -1
  14. linkml/generators/golrgen.py +1 -1
  15. linkml/generators/graphqlgen.py +1 -1
  16. linkml/generators/javagen.py +1 -1
  17. linkml/generators/jsonldcontextgen.py +4 -4
  18. linkml/generators/jsonldgen.py +1 -1
  19. linkml/generators/jsonschemagen.py +69 -22
  20. linkml/generators/linkmlgen.py +1 -1
  21. linkml/generators/markdowngen.py +1 -1
  22. linkml/generators/namespacegen.py +1 -1
  23. linkml/generators/oocodegen.py +2 -1
  24. linkml/generators/owlgen.py +1 -1
  25. linkml/generators/plantumlgen.py +1 -1
  26. linkml/generators/prefixmapgen.py +1 -1
  27. linkml/generators/projectgen.py +1 -1
  28. linkml/generators/protogen.py +1 -1
  29. linkml/generators/pydanticgen/__init__.py +8 -3
  30. linkml/generators/pydanticgen/array.py +114 -194
  31. linkml/generators/pydanticgen/build.py +64 -25
  32. linkml/generators/pydanticgen/includes.py +1 -31
  33. linkml/generators/pydanticgen/pydanticgen.py +616 -274
  34. linkml/generators/pydanticgen/template.py +152 -184
  35. linkml/generators/pydanticgen/templates/attribute.py.jinja +9 -7
  36. linkml/generators/pydanticgen/templates/base_model.py.jinja +0 -13
  37. linkml/generators/pydanticgen/templates/class.py.jinja +2 -2
  38. linkml/generators/pydanticgen/templates/footer.py.jinja +2 -10
  39. linkml/generators/pydanticgen/templates/module.py.jinja +2 -2
  40. linkml/generators/pydanticgen/templates/validator.py.jinja +0 -4
  41. linkml/generators/pythongen.py +12 -2
  42. linkml/generators/rdfgen.py +1 -1
  43. linkml/generators/shaclgen.py +6 -2
  44. linkml/generators/shexgen.py +1 -1
  45. linkml/generators/sparqlgen.py +1 -1
  46. linkml/generators/sqlalchemygen.py +1 -1
  47. linkml/generators/sqltablegen.py +1 -1
  48. linkml/generators/sssomgen.py +1 -1
  49. linkml/generators/summarygen.py +1 -1
  50. linkml/generators/terminusdbgen.py +7 -4
  51. linkml/generators/typescriptgen.py +1 -1
  52. linkml/generators/yamlgen.py +1 -1
  53. linkml/generators/yumlgen.py +1 -1
  54. linkml/linter/cli.py +1 -1
  55. linkml/transformers/logical_model_transformer.py +117 -18
  56. linkml/utils/converter.py +1 -1
  57. linkml/utils/execute_tutorial.py +2 -0
  58. linkml/utils/logictools.py +142 -29
  59. linkml/utils/schema_builder.py +7 -6
  60. linkml/utils/schema_fixer.py +1 -1
  61. linkml/utils/sqlutils.py +1 -1
  62. linkml/validator/cli.py +4 -1
  63. linkml/validators/jsonschemavalidator.py +1 -1
  64. linkml/validators/sparqlvalidator.py +1 -1
  65. linkml/workspaces/example_runner.py +1 -1
  66. {linkml-1.8.1.dist-info → linkml-1.8.2.dist-info}/METADATA +2 -2
  67. {linkml-1.8.1.dist-info → linkml-1.8.2.dist-info}/RECORD +70 -64
  68. {linkml-1.8.1.dist-info → linkml-1.8.2.dist-info}/entry_points.txt +1 -1
  69. {linkml-1.8.1.dist-info → linkml-1.8.2.dist-info}/LICENSE +0 -0
  70. {linkml-1.8.1.dist-info → linkml-1.8.2.dist-info}/WHEEL +0 -0
@@ -223,7 +223,7 @@ class ShExGenerator(Generator):
223
223
 
224
224
 
225
225
  @shared_arguments(ShExGenerator)
226
- @click.command()
226
+ @click.command(name="shex")
227
227
  @click.option("-o", "--output", help="Output file name")
228
228
  @click.version_option(__version__, "-V", "--version")
229
229
  def cli(yamlfile, **args):
@@ -188,7 +188,7 @@ class SparqlGenerator(Generator):
188
188
 
189
189
 
190
190
  @shared_arguments(SparqlGenerator)
191
- @click.command()
191
+ @click.command(name="sparql")
192
192
  @click.option("--dir", "-d", help="Directory in which queries will be deposited")
193
193
  @click.version_option(__version__, "-V", "--version")
194
194
  def cli(yamlfile, dir, **kwargs):
@@ -216,7 +216,7 @@ class SQLAlchemyGenerator(Generator):
216
216
  help="Emit FK declarations",
217
217
  )
218
218
  @click.version_option(__version__, "-V", "--version")
219
- @click.command()
219
+ @click.command(name="sqla")
220
220
  def cli(yamlfile, declarative, generate_classes, pydantic, use_foreign_keys=True, **args):
221
221
  """Generate SQL DDL representation"""
222
222
  if pydantic:
@@ -290,7 +290,7 @@ class SQLTableGenerator(Generator):
290
290
 
291
291
 
292
292
  @shared_arguments(SQLTableGenerator)
293
- @click.command()
293
+ @click.command(name="sqltables")
294
294
  @click.option(
295
295
  "--dialect",
296
296
  default="sqlite",
@@ -180,7 +180,7 @@ class SSSOMGenerator(Generator):
180
180
 
181
181
 
182
182
  @shared_arguments(SSSOMGenerator)
183
- @click.command()
183
+ @click.command(name="sssom")
184
184
  @click.option("-o", "--output", help="Output file name")
185
185
  @click.version_option(__version__, "-V", "--version")
186
186
  def cli(yamlfile, **kwargs):
@@ -88,7 +88,7 @@ class SummaryGenerator(Generator):
88
88
 
89
89
  @shared_arguments(SummaryGenerator)
90
90
  @click.version_option(__version__, "-V", "--version")
91
- @click.command()
91
+ @click.command(name="summary")
92
92
  def cli(yamlfile, **args):
93
93
  """Generate TSV summary files for viewing in Excel and the like"""
94
94
  print(SummaryGenerator(yamlfile, **args).serialize(**args))
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import os
3
+ import warnings
3
4
  from dataclasses import dataclass
4
5
  from typing import List
5
6
 
@@ -10,9 +11,7 @@ from linkml_runtime.utils.formatutils import be, camelcase, underscore
10
11
  try:
11
12
  from terminusdb_client.woqlquery import WOQLQuery as WQ
12
13
  except ImportError:
13
- import warnings
14
-
15
- warnings.warn("terminusdb_client is not a requirement of this package, please install it separately")
14
+ WQ = None
16
15
 
17
16
  from linkml._version import __version__
18
17
  from linkml.utils.generator import Generator, shared_arguments
@@ -67,6 +66,10 @@ class TerminusdbGenerator(Generator):
67
66
  raw_additions: List = None
68
67
  clswq: str = None
69
68
 
69
+ def __post_init__(self):
70
+ if WQ is None:
71
+ warnings.warn("terminusdb_client is not a requirement of this package, please install it separately")
72
+
70
73
  def visit_schema(self, inline: bool = False, **kwargs) -> None:
71
74
  self.classes = []
72
75
  self.raw_additions = []
@@ -134,7 +137,7 @@ class TerminusdbGenerator(Generator):
134
137
 
135
138
  @shared_arguments(TerminusdbGenerator)
136
139
  @click.version_option(__version__, "-V", "--version")
137
- @click.command()
140
+ @click.command(name="terminusdb")
138
141
  def cli(yamlfile, **args):
139
142
  """Generate graphql representation of a LinkML model"""
140
143
  print(TerminusdbGenerator(yamlfile, **args).serialize(**args))
@@ -264,7 +264,7 @@ class TypescriptGenerator(OOCodeGenerator):
264
264
  @click.version_option(__version__, "-V", "--version")
265
265
  @click.option("--gen-type-utils/", "-u", help="Generate Type checking utils", is_flag=True)
266
266
  @click.option("--include-induced-slots/", help="Generate slots induced through inheritance", is_flag=True)
267
- @click.command()
267
+ @click.command(name="typescript")
268
268
  def cli(yamlfile, gen_type_utils=False, include_induced_slots=False, **args):
269
269
  """Generate typescript interfaces and types
270
270
 
@@ -36,7 +36,7 @@ class YAMLGenerator(Generator):
36
36
 
37
37
 
38
38
  @shared_arguments(YAMLGenerator)
39
- @click.command()
39
+ @click.command(name="yaml")
40
40
  @click.option(
41
41
  "--raw/--no-raw",
42
42
  default=False,
@@ -262,7 +262,7 @@ class YumlGenerator(Generator):
262
262
 
263
263
 
264
264
  @shared_arguments(YumlGenerator)
265
- @click.command()
265
+ @click.command(name="yuml")
266
266
  @click.option("--classes", "-c", multiple=True, help="Class(es) to emit")
267
267
  @click.option(
268
268
  "--directory",
linkml/linter/cli.py CHANGED
@@ -33,7 +33,7 @@ def get_yaml_files(root: Path, accept_dot_files: bool) -> Iterable[str]:
33
33
  yield str(path)
34
34
 
35
35
 
36
- @click.command()
36
+ @click.command(name="lint")
37
37
  @click.argument(
38
38
  "schema",
39
39
  type=click.Path(exists=True, dir_okay=True, file_okay=True, resolve_path=True, path_type=Path),
@@ -1,23 +1,67 @@
1
1
  """
2
- A model transformer that transforms a schema into logical form with inheritance remaoved.
2
+ A model transformer that transforms a schema into logical form.
3
3
 
4
4
  Formally, this transformer generates a logical model of the schema in which inheritance
5
- of slots/attributes is replaced by boolean conjunctions.
5
+ of slots/attributes is replaced by boolean conjunctions, and the final form is simplified.
6
+
7
+ For example, given a slot `s`:
8
+
9
+ ```
10
+ slots:
11
+ s:
12
+ range: integer
13
+ minimum_value: 1
14
+ ```
15
+
16
+ Which is reused and refined by C and C (where C is_a D):
17
+
18
+ ```
19
+ classes:
20
+ D:
21
+ slots:
22
+ - s
23
+ slot_usage:
24
+ s:
25
+ minimum_value: 2
26
+ C:
27
+ is_a: D
28
+ slot_usage:
29
+ s:
30
+ minimum_value: 3
31
+ ```
32
+
33
+ The hierarchy unrolling step will create an attribute `s` for C that is the conjunction of usages for that
34
+ class, ancestors, and the base slot:
35
+
36
+ ```
37
+ s:
38
+ range: integer
39
+ all_of:
40
+ - minimum_value: 1
41
+ - minimum_value: 2
42
+ - minimum_value: 3
43
+ ```
44
+
45
+ This is then simplified using symbolic reasoning to:
46
+
47
+ ```
48
+ s:
49
+ range: integer
50
+ minimum_value: 3
51
+ ```
52
+
53
+ See logictools.py for the symbolic reasoning engine.
54
+
6
55
 
7
- For example, given a slot s and two classes C and D, where C is_a D. If both C and D
8
- place constraints on s (for example, C may refine the range, or make more restricted
9
- value constraints), then an attribute is generated for C that for which the conjunction
10
- of constraints in both classes hold.
11
56
 
12
- These logical constraints are then simplified by translating to disjunctive normal form,
13
- and applying simplification rules.
14
57
  """
15
58
 
16
59
  import logging
17
60
  from copy import deepcopy
18
61
  from dataclasses import dataclass
19
- from typing import Any, Callable, Dict, Iterator, List, Union
62
+ from typing import Any, Callable, Dict, Iterator, List, Optional, Union
20
63
 
64
+ from linkml_runtime import SchemaView
21
65
  from linkml_runtime.dumpers import json_dumper
22
66
  from linkml_runtime.linkml_model import ClassDefinitionName, SchemaDefinition
23
67
  from linkml_runtime.linkml_model.meta import (
@@ -37,6 +81,8 @@ HERITABLE_METASLOT = [
37
81
  "range_expression",
38
82
  "minimum_value",
39
83
  "maximum_value",
84
+ "minimum_cardinality",
85
+ "maximum_cardinality",
40
86
  "pattern",
41
87
  "structured_pattern",
42
88
  "required",
@@ -64,6 +110,8 @@ DIRECT_ROLL_DOWN = ["multivalued", "key", "identifier", "designates_type"]
64
110
  HERITABLE_TYPE_METASLOT = [
65
111
  "minimum_value",
66
112
  "maximum_value",
113
+ # "minimum_cardinality",
114
+ # "maximum_cardinality",
67
115
  "pattern",
68
116
  "structured_pattern",
69
117
  "all_of",
@@ -103,6 +151,7 @@ class LogicalModelTransformer(ModelTransformer):
103
151
  >>> _ = sb.add_class("Person", slots={"age": {"range": "integer"}}, is_a="Thing")
104
152
  >>> _ = sb.add_class("Organization", slots={"category": {"range": "OrganizationType"}}, is_a="Thing")
105
153
  >>> _ = sb.add_enum("OrganizationType", ["commercial", "non-profit"])
154
+ >>> _ = sb.add_defaults()
106
155
  >>> from linkml.transformers.logical_model_transformer import LogicalModelTransformer
107
156
  >>> tr = LogicalModelTransformer()
108
157
  >>> tr.set_schema(sb.schema)
@@ -113,8 +162,10 @@ class LogicalModelTransformer(ModelTransformer):
113
162
  attributes:
114
163
  id:
115
164
  name: id
165
+ range: string
116
166
  name:
117
167
  name: name
168
+ range: string
118
169
  age:
119
170
  name: age
120
171
  range: integer
@@ -142,6 +193,10 @@ class LogicalModelTransformer(ModelTransformer):
142
193
 
143
194
  ...
144
195
 
196
+ This has "compiled away" the hierarchy. The type of the values of 'entities' for Container can
197
+ be checked against the any_of expression, without needing to know the full class hierarchy.
198
+ This is useful when compiling to a framework that does not support inheritance.
199
+
145
200
  If you are compiling to a framework that does have inheritance, then you can specify that these are
146
201
  preserved:
147
202
 
@@ -173,6 +228,9 @@ class LogicalModelTransformer(ModelTransformer):
173
228
  name: entities2
174
229
  multivalued: true
175
230
  any_of:
231
+ - range: Organization
232
+ - range: Person
233
+
176
234
  ...
177
235
 
178
236
  Note that defaults are always applied after reasoning. So if you set a default_range to
@@ -211,6 +269,10 @@ class LogicalModelTransformer(ModelTransformer):
211
269
  minimum_value: 40
212
270
  maximum_value: 60
213
271
 
272
+ Here the slot-level constraints seem to have been "overridden". In fact, LinkML is monotonic, and there
273
+ is no overriding. In fact the constraints have been combined as a conjunction (And), and then the resulting
274
+ entailed attribute has been *simplified* (age >= 40 & age >= 0 ==> age >= 40).
275
+
214
276
  The reasoning engine can also detect unsatisfiable constraints:
215
277
 
216
278
  >>> from linkml.transformers.logical_model_transformer import UnsatisfiableAttribute
@@ -227,6 +289,9 @@ class LogicalModelTransformer(ModelTransformer):
227
289
  Let's get rid of the problematic class:
228
290
 
229
291
  >>> del sb.schema.classes["YoungAdult"]
292
+
293
+ And try a different example - we will try and override the range of the age slot with a string:
294
+
230
295
  >>> _ = sb.add_class("Person2", is_a="Person", slot_usage={"age": {"range": "string"}})
231
296
  >>> tr.set_schema(sb.schema)
232
297
  >>> try:
@@ -237,8 +302,6 @@ class LogicalModelTransformer(ModelTransformer):
237
302
  Attribute Person2.age is unsatisfiable
238
303
  <BLANKLINE>
239
304
 
240
-
241
-
242
305
  """
243
306
 
244
307
  preserve_class_is_a: bool = False
@@ -277,16 +340,24 @@ class LogicalModelTransformer(ModelTransformer):
277
340
  a lack of range assignment.
278
341
  """
279
342
 
280
- def transform(self, tgt_schema_name: str = None, simplify=True, **kwargs) -> Any:
343
+ reason_over_metamodel_slots: Optional[List[str]] = None
344
+ """
345
+ If set, only reason over the specified metamodel slots.
346
+ """
347
+
348
+ def transform(self, tgt_schema_name: str = None, simplify=True, force_any_of=False, **kwargs) -> Any:
281
349
  """
282
- Transform the schema to a logical model, translating inheritance to boolean expressions.
350
+ Transform the schema using logical reasoning, materializing entailed simple attributes.
283
351
 
284
352
  :param tgt_schema_name:
285
353
  :param simplify: If True (default), simplify the schema after transformation
354
+ :param force_any_of: If True, force the use of any_of expressions for range, even for singletons
286
355
  :return:
287
356
  """
288
357
  sv = self.schemaview
289
- target_schema = deepcopy(self.source_schema)
358
+ target_schemaview = SchemaView(deepcopy(self.source_schema))
359
+ target_schemaview.merge_imports()
360
+ target_schema = target_schemaview.schema
290
361
  for tn, typ in target_schema.types.items():
291
362
  ancs = sv.type_ancestors(tn, reflexive=False)
292
363
  logging.debug(f"Unrolling type {tn}, merging {len(ancs)}")
@@ -312,7 +383,7 @@ class LogicalModelTransformer(ModelTransformer):
312
383
  self._roll_down(target_schema, cn, ancs)
313
384
  self.apply_defaults(target_schema)
314
385
  if simplify:
315
- self.simplify(target_schema)
386
+ self.simplify(target_schema, force_any_of=force_any_of)
316
387
  if self.tidy_slots:
317
388
  target_schema.slots = {}
318
389
  if self.tidy_inheritance:
@@ -366,7 +437,8 @@ class LogicalModelTransformer(ModelTransformer):
366
437
  :return:
367
438
  """
368
439
  new_slot_dict = {}
369
- for p in HERITABLE_METASLOT:
440
+ heritable_metaslots = self.reason_over_metamodel_slots or HERITABLE_METASLOT
441
+ for p in heritable_metaslots:
370
442
  pv = getattr(source, p)
371
443
  if pv is not None and pv != [] and pv != {} and pv is not False:
372
444
  new_slot_dict[p] = pv
@@ -377,7 +449,7 @@ class LogicalModelTransformer(ModelTransformer):
377
449
  sv = self.schemaview
378
450
  if source.range in sv.all_types():
379
451
  typ = sv.get_type(source.range)
380
- for p in set(HERITABLE_TYPE_METASLOT).intersection(HERITABLE_METASLOT):
452
+ for p in set(HERITABLE_TYPE_METASLOT).intersection(heritable_metaslots):
381
453
  pv = getattr(typ, p)
382
454
  if pv is not None and pv != [] and pv != {} and pv is not False:
383
455
  new_slot_dict[p] = pv
@@ -395,6 +467,9 @@ class LogicalModelTransformer(ModelTransformer):
395
467
  """
396
468
  new_slot_dict = {}
397
469
  for p in HERITABLE_TYPE_METASLOT:
470
+ if p in ["repr"]:
471
+ # not on anonymous types
472
+ continue
398
473
  pv = getattr(source, p)
399
474
  if pv is not None and pv != [] and pv != {} and pv is not False:
400
475
  new_slot_dict[p] = pv
@@ -431,7 +506,7 @@ class LogicalModelTransformer(ModelTransformer):
431
506
  for sx in att.all_of + att.exactly_one_of + att.none_of + att.any_of:
432
507
  yield from self._collection_slot_expressions(sx, filter_function)
433
508
 
434
- def simplify(self, target_schema: SchemaDefinition):
509
+ def simplify(self, target_schema: SchemaDefinition, force_any_of=False):
435
510
  for cls in target_schema.classes.values():
436
511
  for att in cls.attributes.values():
437
512
  x = self._as_logical_expression(att)
@@ -441,6 +516,9 @@ class LogicalModelTransformer(ModelTransformer):
441
516
  if logictools.is_contradiction(x):
442
517
  raise UnsatisfiableAttribute(f"Attribute {cls.name}.{att.name} is unsatisfiable")
443
518
  self._simplify_member_ofs(x)
519
+ if force_any_of:
520
+ if not isinstance(x, logictools.Or):
521
+ x = logictools.Or(x)
444
522
  logger.debug(f"Simplified member of: {x}")
445
523
  simplified_att = self._from_logical_expression(x)
446
524
  for k, v in simplified_att.__dict__.items():
@@ -524,6 +602,10 @@ class LogicalModelTransformer(ModelTransformer):
524
602
  def _value_var(self) -> logictools.Variable:
525
603
  return logictools.Variable("value")
526
604
 
605
+ @property
606
+ def _length_var(self) -> logictools.Variable:
607
+ return logictools.Variable("length")
608
+
527
609
  @property
528
610
  def _type_var(self) -> logictools.Variable:
529
611
  return logictools.Variable("type")
@@ -535,10 +617,15 @@ class LogicalModelTransformer(ModelTransformer):
535
617
  exprs = []
536
618
  value_var = self._value_var
537
619
  type_var = self._type_var
620
+ length_var = self._length_var
538
621
  if slot_expression.minimum_value is not None:
539
622
  exprs.append(value_var >= slot_expression.minimum_value)
540
623
  if slot_expression.maximum_value is not None:
541
624
  exprs.append(value_var <= slot_expression.maximum_value)
625
+ if slot_expression.minimum_cardinality is not None:
626
+ exprs.append(length_var >= slot_expression.minimum_cardinality)
627
+ if slot_expression.maximum_cardinality is not None:
628
+ exprs.append(length_var <= slot_expression.maximum_cardinality)
542
629
  if slot_expression.pattern is not None:
543
630
  exprs.append(logictools.Term(MATCHES_PREDICATE, value_var, slot_expression.pattern))
544
631
  if slot_expression.range:
@@ -576,6 +663,7 @@ class LogicalModelTransformer(ModelTransformer):
576
663
  "none_of",
577
664
  "any_of",
578
665
  ]:
666
+ # already accounted for
579
667
  continue
580
668
  v = getattr(slot_expression, p, None)
581
669
  if v is not None and v != [] and v != {}:
@@ -624,6 +712,17 @@ class LogicalModelTransformer(ModelTransformer):
624
712
  return AnonymousSlotExpression(maximum_value=val - 1)
625
713
  else:
626
714
  raise ValueError(f"Unknown predicate {expr.predicate} for {var}")
715
+ elif var == self._length_var:
716
+ if expr.predicate == ">=":
717
+ return AnonymousSlotExpression(minimum_cardinality=val)
718
+ elif expr.predicate == ">":
719
+ return AnonymousSlotExpression(minimum_cardinality=val + 1)
720
+ elif expr.predicate == "<=":
721
+ return AnonymousSlotExpression(maximum_cardinality=val)
722
+ elif expr.predicate == "<":
723
+ return AnonymousSlotExpression(maximum_cardinality=val - 1)
724
+ else:
725
+ raise ValueError(f"Unknown predicate {expr.predicate} for {var}")
627
726
  elif var == self._type_var:
628
727
  if expr.predicate == "in":
629
728
  if val == logictools.UniversalSet():
linkml/utils/converter.py CHANGED
@@ -24,7 +24,7 @@ from linkml.utils.datautils import (
24
24
  )
25
25
 
26
26
 
27
- @click.command()
27
+ @click.command(name="convert")
28
28
  @click.option("--module", "-m", help="Path to python datamodel module")
29
29
  @click.option("--output", "-o", help="Path to output file")
30
30
  @click.option(
@@ -189,6 +189,8 @@ def parse_file_to_blocks(input) -> List[Block]:
189
189
  @click.version_option(__version__, "-V", "--version")
190
190
  def cli(inputs, directory):
191
191
  """
192
+ Execute a tutorial markdown file (eg. those in the /docs/intro/ directory) and
193
+ save the outputs in the given directory
192
194
 
193
195
  Example:
194
196