linkml 1.9.5rc1__py3-none-any.whl → 1.9.5rc2__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 (29) hide show
  1. linkml/cli/main.py +1 -1
  2. linkml/converter/__init__.py +0 -0
  3. linkml/generators/owlgen.py +9 -1
  4. linkml/generators/rustgen/build.py +3 -0
  5. linkml/generators/rustgen/cli.py +19 -1
  6. linkml/generators/rustgen/rustgen.py +169 -21
  7. linkml/generators/rustgen/template.py +51 -6
  8. linkml/generators/rustgen/templates/Cargo.toml.jinja +2 -2
  9. linkml/generators/rustgen/templates/anything.rs.jinja +8 -1
  10. linkml/generators/rustgen/templates/as_key_value.rs.jinja +33 -3
  11. linkml/generators/rustgen/templates/enum.rs.jinja +16 -0
  12. linkml/generators/rustgen/templates/file.rs.jinja +13 -0
  13. linkml/generators/rustgen/templates/lib_shim.rs.jinja +52 -0
  14. linkml/generators/rustgen/templates/poly_trait_property_impl.rs.jinja +3 -1
  15. linkml/generators/rustgen/templates/property.rs.jinja +12 -3
  16. linkml/generators/rustgen/templates/serde_utils.rs.jinja +186 -6
  17. linkml/generators/rustgen/templates/slot_range_as_union.rs.jinja +3 -0
  18. linkml/generators/rustgen/templates/struct.rs.jinja +6 -0
  19. linkml/generators/rustgen/templates/struct_or_subtype_enum.rs.jinja +4 -1
  20. linkml/generators/rustgen/templates/stub_gen.rs.jinja +71 -0
  21. linkml/generators/rustgen/templates/stub_utils.rs.jinja +76 -0
  22. linkml/generators/yarrrmlgen.py +20 -4
  23. linkml/utils/schema_builder.py +2 -0
  24. {linkml-1.9.5rc1.dist-info → linkml-1.9.5rc2.dist-info}/METADATA +1 -1
  25. {linkml-1.9.5rc1.dist-info → linkml-1.9.5rc2.dist-info}/RECORD +29 -25
  26. {linkml-1.9.5rc1.dist-info → linkml-1.9.5rc2.dist-info}/entry_points.txt +1 -1
  27. /linkml/{utils/converter.py → converter/cli.py} +0 -0
  28. {linkml-1.9.5rc1.dist-info → linkml-1.9.5rc2.dist-info}/WHEEL +0 -0
  29. {linkml-1.9.5rc1.dist-info → linkml-1.9.5rc2.dist-info}/licenses/LICENSE +0 -0
linkml/cli/main.py CHANGED
@@ -7,6 +7,7 @@ Gathers all the other linkml click entrypoints and puts them under ``linkml`` :)
7
7
  import click
8
8
 
9
9
  from linkml._version import __version__
10
+ from linkml.converter.cli import cli as linkml_convert
10
11
  from linkml.generators.csvgen import cli as gen_csv
11
12
  from linkml.generators.dbmlgen import cli as gen_dbml
12
13
  from linkml.generators.docgen import cli as gen_doc
@@ -46,7 +47,6 @@ from linkml.generators.yamlgen import cli as gen_yaml
46
47
  from linkml.generators.yarrrmlgen import cli as gen_yarrrml
47
48
  from linkml.generators.yumlgen import cli as gen_yuml
48
49
  from linkml.linter.cli import main as linkml_lint
49
- from linkml.utils.converter import cli as linkml_convert
50
50
  from linkml.utils.execute_tutorial import cli as run_tutorial
51
51
  from linkml.utils.schema_fixer import main as linkml_schema_fixer
52
52
  from linkml.utils.sqlutils import main as linkml_sqldb
File without changes
@@ -229,7 +229,7 @@ class OwlSchemaGenerator(Generator):
229
229
  data = self.graph.serialize(format="turtle" if self.format in ["owl", "ttl"] else self.format)
230
230
  return data
231
231
 
232
- def add_metadata(self, e: Definition, uri: URIRef) -> None:
232
+ def add_metadata(self, e: Union[Definition, PermissibleValue], uri: URIRef) -> None:
233
233
  """
234
234
  Add annotation properties.
235
235
 
@@ -866,6 +866,10 @@ class OwlSchemaGenerator(Generator):
866
866
  if impl.startswith("rdfs:"):
867
867
  return RDFS[impl.split(":")[1]]
868
868
  if isinstance(default_value, str):
869
+ if default_value.startswith("owl:"):
870
+ return OWL[default_value.split(":")[1]]
871
+ if default_value.startswith("rdfs:"):
872
+ return RDFS[default_value.split(":")[1]]
869
873
  return URIRef(default_value)
870
874
  return default_value
871
875
 
@@ -873,6 +877,8 @@ class OwlSchemaGenerator(Generator):
873
877
  g = self.graph
874
878
  enum_uri = self._enum_uri(e.name)
875
879
  g.add((enum_uri, RDF.type, OWL.Class))
880
+
881
+ self.add_metadata(e, enum_uri)
876
882
  has_parent = False
877
883
  if e.is_a:
878
884
  self.graph.add((enum_uri, RDFS.subClassOf, self._enum_uri(e.is_a)))
@@ -916,6 +922,7 @@ class OwlSchemaGenerator(Generator):
916
922
  )
917
923
  )
918
924
  if not isinstance(pv_node, Literal):
925
+ self.add_metadata(pv, pv_node)
919
926
  g.add((pv_node, RDF.type, pv_owl_type))
920
927
  g.add((pv_node, RDFS.label, Literal(pv.text)))
921
928
  # TODO: make this configurable
@@ -936,6 +943,7 @@ class OwlSchemaGenerator(Generator):
936
943
  if not has_parent and self.add_root_classes:
937
944
  self.graph.add((pv_node, RDFS.subClassOf, URIRef(PermissibleValue.class_class_uri)))
938
945
  if all([pv is not None for pv in pv_uris]):
946
+ # every single PV in the enum is not-null
939
947
  all_is_class = all([owl_type == OWL.Class for owl_type in owl_types])
940
948
  all_is_individual = all([owl_type == OWL.NamedIndividual for owl_type in owl_types])
941
949
  all_is_literal = all([owl_type == RDFS.Literal for owl_type in owl_types])
@@ -1,3 +1,5 @@
1
+ from pydantic import Field
2
+
1
3
  from linkml.generators.common.build import (
2
4
  BuildResult,
3
5
  SchemaResult,
@@ -84,6 +86,7 @@ class CrateResult(RustBuildResult, SchemaResult):
84
86
  file: RustFile
85
87
  extra_files: dict[str, RustTemplateModel]
86
88
  pyproject: RustPyProject
89
+ bin_files: dict[str, RustTemplateModel] = Field(default_factory=dict)
87
90
 
88
91
 
89
92
  class FileResult(RustBuildResult, SchemaResult):
@@ -40,6 +40,14 @@ from linkml.utils.generator import shared_arguments
40
40
  "this flag only enables the crate feature by default."
41
41
  ),
42
42
  )
43
+ @click.option(
44
+ "--handwritten-lib/--no-handwritten-lib",
45
+ default=False,
46
+ help=(
47
+ "When enabled, place generated sources under src/generated and create a shim lib.rs for handwritten code. "
48
+ "The shim is only created on first run and left untouched on subsequent regenerations."
49
+ ),
50
+ )
43
51
  @click.option("-n", "--crate-name", type=str, default=None, help="Name of the generated crate/module")
44
52
  @click.option(
45
53
  "-o",
@@ -56,10 +64,20 @@ def cli(
56
64
  pyo3: bool = False,
57
65
  serde: bool = False,
58
66
  crate_name: Optional[str] = None,
67
+ handwritten_lib: bool = False,
59
68
  output: Optional[Path] = None,
60
69
  **kwargs,
61
70
  ):
62
- gen = RustGenerator(yamlfile, mode=mode, pyo3=pyo3, serde=serde, output=output, crate_name=crate_name, **kwargs)
71
+ gen = RustGenerator(
72
+ yamlfile,
73
+ mode=mode,
74
+ pyo3=pyo3,
75
+ serde=serde,
76
+ output=output,
77
+ crate_name=crate_name,
78
+ handwritten_lib=handwritten_lib,
79
+ **kwargs,
80
+ )
63
81
  serialized = gen.serialize(force=force)
64
82
  if output is None:
65
83
  print(serialized)
@@ -44,6 +44,7 @@ from linkml.generators.rustgen.template import (
44
44
  RustEnum,
45
45
  RustEnumItem,
46
46
  RustFile,
47
+ RustLibShim,
47
48
  RustProperty,
48
49
  RustPyProject,
49
50
  RustRange,
@@ -53,6 +54,8 @@ from linkml.generators.rustgen.template import (
53
54
  RustTypeAlias,
54
55
  SerdeUtilsFile,
55
56
  SlotRangeAsUnion,
57
+ StubGenBin,
58
+ StubUtilsFile,
56
59
  )
57
60
  from linkml.utils.generator import Generator
58
61
 
@@ -143,6 +146,22 @@ PYTHON_IMPORTS = Imports(
143
146
  ]
144
147
  )
145
148
 
149
+ STUBGEN_IMPORTS = Imports(
150
+ imports=[
151
+ Import(
152
+ module="pyo3-stub-gen",
153
+ version="0.13.1",
154
+ objects=[
155
+ ObjectImport(name="define_stub_info_gatherer"),
156
+ ObjectImport(name="derive::gen_stub_pyclass"),
157
+ ObjectImport(name="derive::gen_stub_pymethods"),
158
+ ],
159
+ feature_flag="stubgen",
160
+ feature_dependencies=["pyo3"],
161
+ ),
162
+ ]
163
+ )
164
+
146
165
 
147
166
  class SlotContainerMode(Enum):
148
167
  SINGLE_VALUE = "single_value"
@@ -369,6 +388,10 @@ class RustGenerator(Generator, LifecycleMixin):
369
388
  pyo3_version: str = ">=0.21.1"
370
389
  serde: bool = True
371
390
  """Generate serde derive serialization/deserialization attributes"""
391
+ stubgen: bool = True
392
+ """Generate pyo3-stub-gen instrumentation alongside PyO3 bindings"""
393
+ handwritten_lib: bool = False
394
+ """Place generated sources under src/generated and leave src/lib.rs for user code"""
372
395
  mode: RUST_MODES = "crate"
373
396
  """Generate a cargo.toml file"""
374
397
  output: Optional[Path] = None
@@ -386,12 +409,33 @@ class RustGenerator(Generator, LifecycleMixin):
386
409
  self.schemaview: SchemaView = SchemaView(self.schema)
387
410
  super().__post_init__()
388
411
 
412
+ def _select_root_class(self, class_defs: list[ClassDefinition]) -> Optional[ClassDefinition]:
413
+ """Return the schema-local class marked ``tree_root`` if present."""
414
+
415
+ schema_id = getattr(self.schemaview.schema, "id", None)
416
+
417
+ def is_local(cls: ClassDefinition) -> bool:
418
+ if schema_id is None:
419
+ return cls.from_schema is None
420
+ return cls.from_schema == schema_id
421
+
422
+ local_classes = [cls for cls in class_defs if is_local(cls) and not getattr(cls, "mixin", False)]
423
+
424
+ for cls in local_classes:
425
+ if getattr(cls, "tree_root", False):
426
+ return cls
427
+
428
+ return None
429
+
389
430
  def generate_type(self, type_: TypeDefinition) -> TypeResult:
390
431
  type_ = self.before_generate_type(type_, self.schemaview)
391
432
  res = TypeResult(
392
433
  source=type_,
393
434
  type_=RustTypeAlias(
394
- name=get_name(type_), type_=get_rust_type(type_.base, self.schemaview, self.pyo3), pyo3=self.pyo3
435
+ name=get_name(type_),
436
+ type_=get_rust_type(type_.base, self.schemaview, self.pyo3),
437
+ pyo3=self.pyo3,
438
+ stubgen=self.stubgen,
395
439
  ),
396
440
  imports=self.get_imports(type_),
397
441
  )
@@ -414,6 +458,7 @@ class RustGenerator(Generator, LifecycleMixin):
414
458
  items=items,
415
459
  pyo3=self.pyo3,
416
460
  serde=self.serde,
461
+ stubgen=self.stubgen,
417
462
  ),
418
463
  )
419
464
  res = self.after_generate_enum(res, self.schemaview)
@@ -435,6 +480,7 @@ class RustGenerator(Generator, LifecycleMixin):
435
480
  multivalued=slot.multivalued,
436
481
  pyo3=self.pyo3,
437
482
  class_range=class_range,
483
+ stubgen=self.stubgen,
438
484
  ),
439
485
  imports=self.get_imports(slot),
440
486
  )
@@ -466,6 +512,7 @@ class RustGenerator(Generator, LifecycleMixin):
466
512
  SlotRangeAsUnion(
467
513
  slot_name=get_name(a),
468
514
  ranges=[get_rust_type(r, self.schemaview, True) for r in ranges],
515
+ stubgen=self.stubgen,
469
516
  )
470
517
  )
471
518
 
@@ -473,6 +520,7 @@ class RustGenerator(Generator, LifecycleMixin):
473
520
  class_name=get_name(cls),
474
521
  class_name_snakecase=underscore(uncamelcase(cls.name)),
475
522
  slot_ranges=slot_range_unions,
523
+ stubgen=self.stubgen,
476
524
  )
477
525
 
478
526
  attributes = [self.generate_attribute(attr, cls) for attr in induced_attrs]
@@ -489,6 +537,7 @@ class RustGenerator(Generator, LifecycleMixin):
489
537
  unsendable=unsendable,
490
538
  pyo3=self.pyo3,
491
539
  serde=self.serde,
540
+ stubgen=self.stubgen,
492
541
  as_key_value=self.generate_class_as_key_value(cls),
493
542
  struct_or_subtype_enum=self.gen_struct_or_subtype_enum(cls),
494
543
  class_module=cls_mod,
@@ -511,12 +560,17 @@ class RustGenerator(Generator, LifecycleMixin):
511
560
  values = get_accepted_type_designator_values(self.schemaview, td, d_class)
512
561
  td_mapping[d] = values
513
562
  if len(descendants) > 0:
563
+ key_type = "String"
564
+ key_slot = get_key_or_identifier_slot(cls, self.schemaview)
565
+ if key_slot is not None:
566
+ key_type = get_rust_type(key_slot.range, self.schemaview, self.pyo3)
514
567
  return RustStructOrSubtypeEnum(
515
568
  enum_name=get_name(cls) + "OrSubtype",
516
569
  struct_names=[get_name(self.schemaview.get_class(d)) for d in descendants],
517
570
  type_designator_name=get_name(td) if td else None,
518
571
  as_key_value=get_key_or_identifier_slot(cls, self.schemaview) is not None,
519
572
  type_designators=td_mapping,
573
+ key_property_type=key_type,
520
574
  )
521
575
  return None
522
576
 
@@ -525,6 +579,7 @@ class RustGenerator(Generator, LifecycleMixin):
525
579
  key_attr = None
526
580
  value_attrs = []
527
581
  value_args_no_default = []
582
+ non_key_attrs = []
528
583
 
529
584
  for attr in induced_attrs:
530
585
  if attr.identifier:
@@ -538,6 +593,7 @@ class RustGenerator(Generator, LifecycleMixin):
538
593
  return None
539
594
  key_attr = attr
540
595
  else:
596
+ non_key_attrs.append(attr)
541
597
  if not attr.multivalued:
542
598
  value_attrs.append(attr)
543
599
  if attr.required:
@@ -547,16 +603,27 @@ class RustGenerator(Generator, LifecycleMixin):
547
603
  # attribute to serve as the value, do not treat this as a key/value class.
548
604
  if len(value_attrs) == 0:
549
605
  return None
606
+ value_attr = value_attrs[0]
607
+ simple_dict_possible = (
608
+ len(non_key_attrs) == 1
609
+ and not value_attr.multivalued
610
+ and (
611
+ value_attr.range not in self.schemaview.all_classes()
612
+ or not bool(getattr(value_attr, "inlined", False))
613
+ )
614
+ )
550
615
  return AsKeyValue(
551
616
  name=get_name(cls),
552
617
  key_property_name=get_name(key_attr),
553
618
  key_property_type=get_rust_type(key_attr.range, self.schemaview, self.pyo3),
554
- value_property_name=get_name(value_attrs[0]),
555
- value_property_type=get_rust_type(value_attrs[0].range, self.schemaview, self.pyo3),
556
- can_convert_from_primitive=len(value_args_no_default) <= 1,
619
+ value_property_name=get_name(value_attr),
620
+ value_property_type=get_rust_type(value_attr.range, self.schemaview, self.pyo3),
621
+ can_convert_from_primitive=simple_dict_possible,
557
622
  can_convert_from_empty=len(value_args_no_default) == 0,
623
+ value_property_optional=not bool(value_attr.required),
558
624
  serde=self.serde,
559
625
  pyo3=self.pyo3,
626
+ stubgen=self.stubgen,
560
627
  )
561
628
  return None
562
629
 
@@ -583,6 +650,7 @@ class RustGenerator(Generator, LifecycleMixin):
583
650
  and self.generate_class_as_key_value(self.schemaview.get_class(attr.range)) is not None,
584
651
  pyo3=self.pyo3,
585
652
  serde=self.serde,
653
+ stubgen=self.stubgen,
586
654
  ),
587
655
  imports=self.get_imports(attr),
588
656
  )
@@ -602,6 +670,7 @@ class RustGenerator(Generator, LifecycleMixin):
602
670
  pyo3_version=self.pyo3_version,
603
671
  pyo3=self.pyo3,
604
672
  serde=self.serde,
673
+ stubgen=self.stubgen,
605
674
  )
606
675
 
607
676
  def generate_pyproject(self) -> RustPyProject:
@@ -658,7 +727,10 @@ class RustGenerator(Generator, LifecycleMixin):
658
727
  slots = self.after_generate_slots(slots, sv)
659
728
 
660
729
  need_merge_crate = False
661
- classes = list(sv.induced_class(c) for c in sv.all_classes(ordered_by=OrderedBy.INHERITANCE))
730
+ class_defs = [sv.induced_class(c) for c in sv.all_classes(ordered_by=OrderedBy.INHERITANCE)]
731
+ root_class_def = self._select_root_class(class_defs)
732
+ root_struct_name = get_name(root_class_def) if root_class_def is not None else None
733
+ classes = class_defs
662
734
  for c in classes:
663
735
  if MERGE_ANNOTATION in c.annotations:
664
736
  need_merge_crate = True
@@ -673,6 +745,8 @@ class RustGenerator(Generator, LifecycleMixin):
673
745
  imports = DEFAULT_IMPORTS.model_copy()
674
746
  imports += PYTHON_IMPORTS
675
747
  imports += SERDE_IMPORTS
748
+ if self.stubgen:
749
+ imports += STUBGEN_IMPORTS
676
750
  if need_merge_crate:
677
751
  imports += MERGE_IMPORTS
678
752
  for result in [*enums, *slots, *classes]:
@@ -687,6 +761,9 @@ class RustGenerator(Generator, LifecycleMixin):
687
761
  structs=[c.cls for c in classes],
688
762
  pyo3=self.pyo3,
689
763
  serde=self.serde,
764
+ stubgen=self.stubgen,
765
+ handwritten_lib=self.handwritten_lib,
766
+ root_struct_name=root_struct_name,
690
767
  )
691
768
 
692
769
  if mode == "crate":
@@ -694,9 +771,21 @@ class RustGenerator(Generator, LifecycleMixin):
694
771
  extra_files["serde_utils"] = SerdeUtilsFile()
695
772
  extra_files["poly"] = PolyFile(imports=imports, traits=poly_traits)
696
773
  extra_files["poly_containers"] = PolyContainersFile()
774
+ if self.stubgen:
775
+ extra_files["stub_utils"] = StubUtilsFile()
697
776
  cargo = self.generate_cargo(imports)
698
777
  pyproject = self.generate_pyproject()
699
- res = CrateResult(cargo=cargo, file=file, pyproject=pyproject, source=sv.schema, extra_files=extra_files)
778
+ bin_files = {}
779
+ if self.stubgen:
780
+ bin_files["bin/stub_gen"] = StubGenBin(crate_name=cargo.name, stubgen=self.stubgen)
781
+ res = CrateResult(
782
+ cargo=cargo,
783
+ file=file,
784
+ pyproject=pyproject,
785
+ source=sv.schema,
786
+ extra_files=extra_files,
787
+ bin_files=bin_files,
788
+ )
700
789
  return res
701
790
  else:
702
791
  # Single file: inline serde utils, and skip poly modules
@@ -973,33 +1062,54 @@ class RustGenerator(Generator, LifecycleMixin):
973
1062
  rendered = self.render(mode="crate")
974
1063
 
975
1064
  cargo = rendered.cargo.render(self.template_environment)
976
- # Normalize EOF: exactly one trailing newline
977
- cargo = cargo.rstrip("\n") + "\n"
978
1065
  cargo_file = output / "Cargo.toml"
979
- with open(cargo_file, "w") as cfile:
980
- cfile.write(cargo)
1066
+ self._write_text_file(cargo_file, cargo, crate_root=output)
981
1067
 
982
1068
  pyproject = rendered.pyproject.render(self.template_environment)
983
- pyproject = pyproject.rstrip("\n") + "\n"
984
1069
  pyproject_file = output / "pyproject.toml"
985
- with open(pyproject_file, "w") as pyfile:
986
- pyfile.write(pyproject)
1070
+ self._write_text_file(pyproject_file, pyproject, crate_root=output)
987
1071
 
988
1072
  rust_file = rendered.file.render(self.template_environment)
989
- rust_file = rust_file.rstrip("\n") + "\n"
990
1073
  src_dir = output / "src"
991
1074
  src_dir.mkdir(exist_ok=True)
992
- lib_file = src_dir / "lib.rs"
993
- with open(lib_file, "w") as lfile:
994
- lfile.write(rust_file)
1075
+ if self.handwritten_lib:
1076
+ generated_dir = src_dir / "generated"
1077
+ generated_dir.mkdir(exist_ok=True)
1078
+ lib_file = generated_dir / "mod.rs"
1079
+ else:
1080
+ generated_dir = src_dir
1081
+ lib_file = src_dir / "lib.rs"
1082
+ self._write_text_file(lib_file, rust_file, crate_root=output)
995
1083
 
996
1084
  for k, f in rendered.extra_files.items():
997
1085
  extra_file = f.render(self.template_environment)
998
- extra_file = extra_file.rstrip("\n") + "\n"
999
1086
  extra_file_name = f"{k}.rs"
1000
- extra_file_path = src_dir / extra_file_name
1001
- with open(extra_file_path, "w") as ef:
1002
- ef.write(extra_file)
1087
+ extra_file_path = self._safe_subpath(generated_dir, extra_file_name)
1088
+ self._write_text_file(extra_file_path, extra_file, crate_root=output)
1089
+
1090
+ if getattr(rendered, "bin_files", None):
1091
+ for rel_path, template in rendered.bin_files.items():
1092
+ rendered_bin = template.render(self.template_environment)
1093
+ safe_bin_base = self._safe_subpath(src_dir, rel_path)
1094
+ bin_path = safe_bin_base.with_suffix(".rs")
1095
+ self._write_text_file(bin_path, rendered_bin, crate_root=output)
1096
+
1097
+ if self.handwritten_lib:
1098
+ shim_path = src_dir / "lib.rs"
1099
+ if not shim_path.exists():
1100
+ root_struct_name = getattr(rendered.file, "root_struct_name", None)
1101
+ root_struct_fn_snake = underscore(uncamelcase(root_struct_name)) if root_struct_name else None
1102
+ shim_template = RustLibShim(
1103
+ module_name=rendered.file.name,
1104
+ pyo3=self.pyo3,
1105
+ serde=self.serde,
1106
+ stubgen=self.stubgen,
1107
+ handwritten_lib=self.handwritten_lib,
1108
+ root_struct_name=root_struct_name,
1109
+ root_struct_fn_snake=root_struct_fn_snake,
1110
+ )
1111
+ shim = shim_template.render(self.template_environment)
1112
+ self._write_text_file(shim_path, shim, crate_root=output)
1003
1113
 
1004
1114
  return rust_file
1005
1115
 
@@ -1031,6 +1141,44 @@ class RustGenerator(Generator, LifecycleMixin):
1031
1141
 
1032
1142
  return output
1033
1143
 
1144
+ def _safe_subpath(self, base: Path, relative: Union[str, Path]) -> Path:
1145
+ """Return a path nested under base, validating it does not escape."""
1146
+
1147
+ rel_path = Path(relative)
1148
+ if rel_path.is_absolute():
1149
+ raise ValueError(f"Relative path expected, got absolute path: {relative}")
1150
+
1151
+ if not rel_path.parts:
1152
+ raise ValueError("Relative path must contain at least one segment")
1153
+
1154
+ for part in rel_path.parts:
1155
+ if part in (".", ".."):
1156
+ raise ValueError(f"Invalid path segment: {part}")
1157
+ if "/" in part or "\\" in part:
1158
+ raise ValueError(f"Path segment must not contain separators: {part}")
1159
+
1160
+ candidate = base / rel_path
1161
+ base_resolved = base.resolve()
1162
+ try:
1163
+ candidate.resolve().relative_to(base_resolved)
1164
+ except ValueError as exc: # pragma: no cover - defensive
1165
+ raise ValueError(f"Path {candidate} escapes base directory {base}") from exc
1166
+
1167
+ return candidate
1168
+
1169
+ def _write_text_file(self, path: Path, content: str, *, crate_root: Path) -> None:
1170
+ """Normalize trailing newline, ensure parent dirs, and write text."""
1171
+
1172
+ base_resolved = crate_root.resolve()
1173
+ try:
1174
+ path.resolve().relative_to(base_resolved)
1175
+ except ValueError as exc:
1176
+ raise ValueError(f"Path {path} escapes crate root {crate_root}") from exc
1177
+
1178
+ normalized = content.rstrip("\n") + "\n"
1179
+ path.parent.mkdir(parents=True, exist_ok=True)
1180
+ path.write_text(normalized)
1181
+
1034
1182
  @property
1035
1183
  def template_environment(self) -> Environment:
1036
1184
  if self._environment is None:
@@ -221,6 +221,12 @@ class RustTemplateModel(TemplateModel):
221
221
  """
222
222
  Whether serde serialization/deserialization annotations should be added.
223
223
  """
224
+ stubgen: bool = False
225
+ """
226
+ Whether pyo3-stub-gen instrumentation should be emitted.
227
+ """
228
+ handwritten_lib: bool = False
229
+ """Place generated sources under src/generated and leave lib.rs untouched for user code."""
224
230
  attributes: dict[str, str] = Field(default_factory=dict)
225
231
 
226
232
 
@@ -297,6 +303,8 @@ class Import(Import_, RustTemplateModel):
297
303
  """Features to require in Cargo.toml"""
298
304
  ## whether this import should be behind a feature flag
299
305
  feature_flag: Optional[str] = None
306
+ feature_dependencies: list[str] = Field(default_factory=list)
307
+ """Additional crate features to enable alongside the optional dependency."""
300
308
 
301
309
 
302
310
  class Imports(Imports_, RustTemplateModel):
@@ -362,6 +370,7 @@ class AsKeyValue(RustTemplateModel):
362
370
  value_property_type: str
363
371
  can_convert_from_primitive: bool = False
364
372
  can_convert_from_empty: bool = False
373
+ value_property_optional: bool = False
365
374
 
366
375
 
367
376
  class RustStructOrSubtypeEnum(RustTemplateModel):
@@ -371,6 +380,7 @@ class RustStructOrSubtypeEnum(RustTemplateModel):
371
380
  as_key_value: bool = False
372
381
  type_designator_field: Optional[str] = None
373
382
  type_designators: dict[str, str]
383
+ key_property_type: str = "String"
374
384
 
375
385
 
376
386
  class SlotRangeAsUnion(RustTemplateModel):
@@ -519,6 +529,26 @@ class SerdeUtilsFile(RustTemplateModel):
519
529
  template: ClassVar[str] = "serde_utils.rs.jinja"
520
530
 
521
531
 
532
+ class StubUtilsFile(RustTemplateModel):
533
+ """Helper utilities shared by stub generation code."""
534
+
535
+ template: ClassVar[str] = "stub_utils.rs.jinja"
536
+
537
+
538
+ class StubGenBin(RustTemplateModel):
539
+ """Binary entry point to orchestrate stub generation checks."""
540
+
541
+ template: ClassVar[str] = "stub_gen.rs.jinja"
542
+ crate_name: str
543
+ stubgen: bool
544
+
545
+ @computed_field
546
+ def crate_module(self) -> str:
547
+ """Crate identifier usable in Rust source."""
548
+
549
+ return self.crate_name.replace("-", "_")
550
+
551
+
522
552
  class PolyTraitProperty(RustTemplateModel):
523
553
  template: ClassVar[str] = "poly_trait_property.rs.jinja"
524
554
  name: str
@@ -763,6 +793,7 @@ class RustFile(RustTemplateModel):
763
793
  inline_serde_utils: bool = False
764
794
  emit_poly: bool = True
765
795
  serde_utils: Optional[SerdeUtilsFile] = None
796
+ root_struct_name: Optional[str] = None
766
797
 
767
798
  @computed_field
768
799
  def struct_names(self) -> list[str]:
@@ -814,14 +845,19 @@ class RustCargo(RustTemplateModel):
814
845
 
815
846
  @computed_field
816
847
  def cratefeatures(self) -> dict[str, list[str]]:
817
- feature_flags = {}
848
+ feature_flags: dict[str, list[str]] = {}
818
849
  for i in self.imports.imports:
819
850
  assert isinstance(i, Import)
820
- if i.feature_flag is not None:
821
- if i.feature_flag not in feature_flags:
822
- feature_flags[i.feature_flag] = [i.module]
823
- else:
824
- feature_flags[i.feature_flag].append(i.module)
851
+ if i.feature_flag is None:
852
+ continue
853
+ deps = feature_flags.setdefault(i.feature_flag, [])
854
+ module = i.module.split("::")[0]
855
+ dep_entry = f"dep:{module}"
856
+ if dep_entry not in deps:
857
+ deps.append(dep_entry)
858
+ for extra in i.feature_dependencies:
859
+ if extra not in deps:
860
+ deps.append(extra)
825
861
  return feature_flags
826
862
 
827
863
  @field_validator("name", mode="after")
@@ -863,3 +899,12 @@ class RustPyProject(RustTemplateModel):
863
899
  @classmethod
864
900
  def snake_case_name(cls, value: str) -> str:
865
901
  return underscore(value)
902
+
903
+
904
+ class RustLibShim(RustTemplateModel):
905
+ """Shim module that re-exports generated code and hosts the PyO3 entry point."""
906
+
907
+ template: ClassVar[str] = "lib_shim.rs.jinja"
908
+ module_name: str
909
+ root_struct_name: Optional[str] = None
910
+ root_struct_fn_snake: Optional[str] = None
@@ -6,7 +6,7 @@ edition = "{{ edition }}"
6
6
  [lib]
7
7
  name = "{{ name }}"
8
8
  {% if pyo3 %}
9
- crate-type = ["cdylib"]
9
+ crate-type = ["cdylib", "rlib"]
10
10
  {% else %}
11
11
  crate-type = ["lib"]
12
12
  {% endif %}
@@ -38,5 +38,5 @@ default = ["serde"]
38
38
  default = []
39
39
  {% endif %}
40
40
  {% for k,v in cratefeatures.items() %}
41
- {{ k }} = [{% for i in v %}"dep:{{ i }}"{% if not loop.last %}, {% endif %}{% endfor %}]
41
+ {{ k }} = [{% for i in v %}"{{ i }}"{% if not loop.last %}, {% endif %}{% endfor %}]
42
42
  {% endfor %}
@@ -5,6 +5,14 @@ pub struct Anything(
5
5
  );
6
6
 
7
7
 
8
+ #[cfg(feature = "stubgen")]
9
+ impl ::pyo3_stub_gen::PyStubType for Anything {
10
+ fn type_output() -> ::pyo3_stub_gen::TypeInfo {
11
+ ::pyo3_stub_gen::TypeInfo::any()
12
+ }
13
+ }
14
+
15
+
8
16
  #[cfg(feature = "serde")]
9
17
  impl Serialize for Anything {
10
18
  fn serialize<S>(&self, to_ser: S) -> Result<S::Ok, S::Error>
@@ -91,7 +99,6 @@ impl<'py> IntoPyObject<'py> for Anything {
91
99
 
92
100
  fn into_pyobject(self, py: Python<'py>) -> Result<Self::Output, Self::Error> {
93
101
  use pyo3::types::{PyAny, PyDict, PyList, PyString};
94
- use pyo3::PyObject;
95
102
  use serde_value::Value;
96
103
 
97
104
  fn value_to_py<'py>(py: Python<'py>, v: &Value) -> pyo3::PyResult<Bound<'py, PyAny>> {