can-db-model 0.1.0__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.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,17 @@
1
+ from .__about__ import __version__
2
+ from .model import (
3
+ Bus, CanDb, Document, Documentation, Label, Message, MuxMember, Node, Signal,
4
+ )
5
+ from .load import load
6
+ from .from_kcd import load_kcd
7
+ from .from_yaml import load_yaml
8
+ from .to_kcd import dump_kcd, to_kcd_bytes
9
+ from .to_yaml import dump_yaml, to_yaml_str
10
+
11
+ __all__ = [
12
+ "__version__",
13
+ "Bus", "CanDb", "Document", "Documentation", "Label", "Message",
14
+ "MuxMember", "Node", "Signal",
15
+ "load", "load_kcd", "load_yaml",
16
+ "dump_kcd", "to_kcd_bytes", "dump_yaml", "to_yaml_str",
17
+ ]
@@ -0,0 +1,122 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import cantools
6
+ from lxml import etree
7
+
8
+ from .model import CanDb
9
+
10
+ _KCD_NS = "http://kayak.2codeornot2code.org/1.0"
11
+
12
+
13
+ class KcdLoadError(Exception):
14
+ pass
15
+
16
+
17
+ def _xml_facts(path: Path):
18
+ """Recover nodes/producers/consumers/bus that cantools' KCD reader drops."""
19
+ root = etree.parse(str(path)).getroot()
20
+ ns = {"k": _KCD_NS}
21
+
22
+ doc_el = root.find("k:Document", ns)
23
+ document = None
24
+ if doc_el is not None:
25
+ document = {
26
+ "name": doc_el.get("name") or "",
27
+ "version": doc_el.get("version") or "",
28
+ "author": doc_el.get("author") or "",
29
+ }
30
+
31
+ bus = root.find("k:Bus", ns)
32
+ bus_name = bus.get("name")
33
+ bus_baud = int(bus.get("baudrate"))
34
+
35
+ id_to_name: dict[str, str] = {}
36
+ node_names: list[str] = []
37
+ for n in root.findall(".//k:Node", ns):
38
+ id_to_name[n.get("id")] = n.get("name")
39
+ node_names.append(n.get("name"))
40
+
41
+ def _refs(parent, xpath):
42
+ out = []
43
+ for ref in parent.findall(xpath, ns):
44
+ out.append(id_to_name[ref.get("id")])
45
+ return out
46
+
47
+ producers: dict[int, list[str]] = {}
48
+ consumers: dict[int, dict[str, list[str]]] = {}
49
+ for msg in bus.findall("k:Message", ns):
50
+ mid = int(msg.get("id"), 0)
51
+ producers[mid] = _refs(msg, "k:Producer/k:NodeRef")
52
+ per_sig: dict[str, list[str]] = {}
53
+ for sig in (msg.findall(".//k:Signal", ns) + msg.findall(".//k:Multiplex", ns)):
54
+ cons = _refs(sig, "k:Consumer/k:NodeRef")
55
+ if cons:
56
+ per_sig[sig.get("name")] = cons
57
+ consumers[mid] = per_sig
58
+ return document, bus_name, bus_baud, node_names, producers, consumers
59
+
60
+
61
+ def _value_type(sig) -> str:
62
+ if sig.is_float:
63
+ return "double" if sig.length == 64 else "single"
64
+ return "signed" if sig.is_signed else "unsigned"
65
+
66
+
67
+ def _mux_field(sig):
68
+ if sig.is_multiplexer:
69
+ return "multiplexor"
70
+ if sig.multiplexer_signal is not None:
71
+ return {"of": sig.multiplexer_signal, "group": sig.multiplexer_ids[0]}
72
+ return None
73
+
74
+
75
+ def load_kcd(path: Path | str) -> CanDb:
76
+ path = Path(path)
77
+ try:
78
+ db = cantools.database.load_file(path, database_format="kcd")
79
+ except Exception as e:
80
+ raise KcdLoadError(f"failed to load KCD {path}: {e}") from e
81
+ document, bus_name, bus_baud, node_names, producers, consumers = _xml_facts(path)
82
+
83
+ messages = []
84
+ for m in db.messages:
85
+ sigs = []
86
+ for s in m.signals:
87
+ sigs.append({
88
+ "name": s.name,
89
+ "start": s.start,
90
+ "length": s.length,
91
+ "endianness": "big" if s.byte_order == "big_endian" else "little",
92
+ "type": _value_type(s),
93
+ "slope": s.scale if s.scale is not None else 1.0,
94
+ "intercept": s.offset if s.offset is not None else 0.0,
95
+ "unit": s.unit,
96
+ "min": s.minimum,
97
+ "max": s.maximum,
98
+ "consumers": consumers.get(m.frame_id, {}).get(s.name, []),
99
+ "labels": ({int(v): str(n) for v, n in s.choices.items()}
100
+ if s.choices else {}),
101
+ "mux": _mux_field(s),
102
+ "doc": ({"kcd_notes": s.comment} if s.comment else None),
103
+ })
104
+ messages.append({
105
+ "name": m.name,
106
+ "id": m.frame_id,
107
+ "extended": m.is_extended_frame,
108
+ "length": m.length,
109
+ "interval": m.cycle_time,
110
+ "producers": producers.get(m.frame_id, []),
111
+ "signals": sigs,
112
+ "doc": ({"kcd_notes": m.comment} if m.comment else None),
113
+ })
114
+
115
+ payload = {
116
+ "bus": {"name": bus_name, "baudrate": bus_baud},
117
+ "nodes": node_names,
118
+ "messages": messages,
119
+ }
120
+ if document is not None:
121
+ payload["document"] = document
122
+ return CanDb.model_validate(payload)
@@ -0,0 +1,34 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+ from pydantic import ValidationError
7
+
8
+ from .model import CanDb
9
+
10
+
11
+ class YamlLoadError(Exception):
12
+ pass
13
+
14
+
15
+ def _format_validation_error(path: Path, err: ValidationError) -> str:
16
+ lines = [f"{path}: invalid CAN database:"]
17
+ for e in err.errors():
18
+ loc = ".".join(str(p) for p in e["loc"])
19
+ lines.append(f" {loc or '<root>'}: {e['msg']}")
20
+ return "\n".join(lines)
21
+
22
+
23
+ def load_yaml(path: Path | str) -> CanDb:
24
+ path = Path(path)
25
+ try:
26
+ data = yaml.safe_load(path.read_text())
27
+ except yaml.YAMLError as e:
28
+ raise YamlLoadError(f"{path}: YAML syntax error: {e}") from e
29
+ if not isinstance(data, dict):
30
+ raise YamlLoadError(f"{path}: top level must be a mapping")
31
+ try:
32
+ return CanDb.model_validate(data)
33
+ except ValidationError as e:
34
+ raise YamlLoadError(_format_validation_error(path, e)) from e
can_db_model/load.py ADDED
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from .from_kcd import load_kcd
6
+ from .from_yaml import load_yaml
7
+ from .model import CanDb
8
+
9
+
10
+ def load(path: Path | str) -> CanDb:
11
+ path = Path(path)
12
+ ext = path.suffix.lower()
13
+ if ext == ".kcd":
14
+ return load_kcd(path)
15
+ if ext in (".yaml", ".yml"):
16
+ return load_yaml(path)
17
+ raise ValueError(
18
+ f"{path}: unsupported extension {ext!r} (expected .kcd/.yaml/.yml)")
can_db_model/model.py ADDED
@@ -0,0 +1,167 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal, Optional, Union
4
+
5
+ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
6
+
7
+ Endianness = Literal["little", "big"]
8
+ ValueType = Literal["unsigned", "signed", "single", "double"]
9
+
10
+ _STRICT = ConfigDict(extra="forbid")
11
+
12
+
13
+ class Documentation(BaseModel):
14
+ model_config = _STRICT
15
+ kcd_notes: Optional[str] = None
16
+ comments: list[str] = Field(default_factory=list)
17
+ description: Optional[str] = None
18
+ tooltip: Optional[str] = None
19
+
20
+
21
+ class Label(BaseModel):
22
+ model_config = _STRICT
23
+ value: int
24
+ name: str
25
+
26
+
27
+ class MuxMember(BaseModel):
28
+ model_config = _STRICT
29
+ of: str
30
+ group: int
31
+
32
+
33
+ class Signal(BaseModel):
34
+ model_config = _STRICT
35
+ name: str
36
+ start: int
37
+ length: int
38
+ endianness: Endianness = "little"
39
+ type: ValueType = "unsigned"
40
+ slope: float = 1.0
41
+ intercept: float = 0.0
42
+ unit: Optional[str] = None
43
+ min: Optional[float] = None
44
+ max: Optional[float] = None
45
+ consumers: list[str] = Field(default_factory=list)
46
+ labels: list[Label] = Field(default_factory=list)
47
+ mux: Union[Literal["multiplexor"], MuxMember, None] = None
48
+ doc: Optional[Documentation] = None
49
+
50
+ @field_validator("labels", mode="before")
51
+ @classmethod
52
+ def _coerce_labels(cls, v):
53
+ # Accept the terse YAML mapping {value: name} as well as a list.
54
+ if isinstance(v, dict):
55
+ return [{"value": int(val), "name": str(name)} for val, name in v.items()]
56
+ return v
57
+
58
+
59
+ class Message(BaseModel):
60
+ model_config = _STRICT
61
+ name: str
62
+ id: int
63
+ extended: Optional[bool] = None
64
+ length: int
65
+ interval: Optional[int] = None
66
+ producers: list[str] = Field(default_factory=list)
67
+ signals: list[Signal] = Field(default_factory=list)
68
+ doc: Optional[Documentation] = None
69
+
70
+ @field_validator("id", mode="before")
71
+ @classmethod
72
+ def _parse_id(cls, v):
73
+ if isinstance(v, str):
74
+ return int(v, 0)
75
+ return v
76
+
77
+ @model_validator(mode="after")
78
+ def _infer_extended(self):
79
+ if self.extended is None:
80
+ self.extended = self.id > 0x7FF
81
+ return self
82
+
83
+
84
+ class Bus(BaseModel):
85
+ model_config = _STRICT
86
+ name: str
87
+ baudrate: int
88
+ doc: Optional[Documentation] = None
89
+
90
+
91
+ class Node(BaseModel):
92
+ model_config = _STRICT
93
+ name: str
94
+ doc: Optional[Documentation] = None
95
+
96
+
97
+ class Document(BaseModel):
98
+ model_config = _STRICT
99
+ name: str
100
+ version: str = ""
101
+ author: str = ""
102
+
103
+
104
+ class CanDb(BaseModel):
105
+ model_config = _STRICT
106
+ document: Optional[Document] = None
107
+ bus: Optional[Bus] = None
108
+ nodes: list[Node] = Field(default_factory=list)
109
+ messages: list[Message] = Field(default_factory=list)
110
+ doc: Optional[Documentation] = None
111
+
112
+ @field_validator("nodes", mode="before")
113
+ @classmethod
114
+ def _coerce_nodes(cls, v):
115
+ if isinstance(v, list):
116
+ return [{"name": n} if isinstance(n, str) else n for n in v]
117
+ return v
118
+
119
+ @model_validator(mode="after")
120
+ def _semantic_checks(self):
121
+ node_names = {n.name for n in self.nodes}
122
+
123
+ seen_ids: set[int] = set()
124
+ for msg in self.messages:
125
+ if msg.id in seen_ids:
126
+ raise ValueError(f"duplicate frame id 0x{msg.id:X} ({msg.name})")
127
+ seen_ids.add(msg.id)
128
+
129
+ for pname in msg.producers:
130
+ if pname not in node_names:
131
+ raise ValueError(
132
+ f"producer node {pname!r} not in nodes ({msg.name})")
133
+
134
+ selectors = {s.name for s in msg.signals if s.mux == "multiplexor"}
135
+ for s in msg.signals:
136
+ for cname in s.consumers:
137
+ if cname not in node_names:
138
+ raise ValueError(
139
+ f"consumer node {cname!r} not in nodes "
140
+ f"({msg.name}.{s.name})")
141
+ if s.start + s.length > msg.length * 8:
142
+ raise ValueError(
143
+ f"signal {s.name} exceeds message length ({msg.name})")
144
+ if isinstance(s.mux, MuxMember) and s.mux.of not in selectors:
145
+ raise ValueError(
146
+ f"mux selector {s.mux.of!r} not found ({msg.name}.{s.name})")
147
+
148
+ # Bit-overlap only among signals that coexist: plain + selector, and
149
+ # within each individual mux group. Members of different groups may
150
+ # share bits.
151
+ def _overlap(sigs):
152
+ spans = sorted((s.start, s.start + s.length, s.name) for s in sigs)
153
+ for (a0, a1, an), (b0, b1, bn) in zip(spans, spans[1:]):
154
+ if b0 < a1:
155
+ raise ValueError(
156
+ f"signals {an} and {bn} overlap ({msg.name})")
157
+
158
+ base = [s for s in msg.signals
159
+ if s.mux is None or s.mux == "multiplexor"]
160
+ _overlap(base)
161
+ groups: dict[tuple[str, int], list] = {}
162
+ for s in msg.signals:
163
+ if isinstance(s.mux, MuxMember):
164
+ groups.setdefault((s.mux.of, s.mux.group), []).append(s)
165
+ for members in groups.values():
166
+ _overlap(base + members)
167
+ return self
can_db_model/to_kcd.py ADDED
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from lxml import etree
6
+
7
+ from .model import CanDb, MuxMember, Signal
8
+
9
+ _NS = "http://kayak.2codeornot2code.org/1.0"
10
+ _NSMAP = {None: _NS}
11
+
12
+
13
+ def _q(tag: str) -> str:
14
+ return f"{{{_NS}}}{tag}"
15
+
16
+
17
+ def _set_attrs(elem, sig: Signal) -> None:
18
+ elem.set("name", sig.name)
19
+ elem.set("offset", str(sig.start))
20
+ elem.set("length", str(sig.length))
21
+ elem.set("endianess", "big" if sig.endianness == "big" else "little")
22
+
23
+
24
+ def _kcd_notes(sig) -> str | None:
25
+ return sig.doc.kcd_notes if sig.doc else None
26
+
27
+
28
+ def _populate(elem, sig: Signal, node_id):
29
+ # KCD child order: Notes, Consumer, Value, LabelSet.
30
+ _set_attrs(elem, sig)
31
+ if _kcd_notes(sig):
32
+ etree.SubElement(elem, _q("Notes")).text = _kcd_notes(sig)
33
+ if sig.consumers:
34
+ cons = etree.SubElement(elem, _q("Consumer"))
35
+ for c in sig.consumers:
36
+ etree.SubElement(cons, _q("NodeRef"), id=node_id[c])
37
+ v = etree.SubElement(elem, _q("Value"), type=sig.type,
38
+ slope=repr(sig.slope), intercept=repr(sig.intercept))
39
+ if sig.unit:
40
+ v.set("unit", sig.unit)
41
+ if sig.labels:
42
+ ls = etree.SubElement(elem, _q("LabelSet"))
43
+ for lab in sig.labels:
44
+ etree.SubElement(ls, _q("Label"), name=lab.name, value=str(lab.value))
45
+
46
+
47
+ def _signal_elem(sig: Signal, node_id):
48
+ s = etree.Element(_q("Signal"))
49
+ _populate(s, sig, node_id)
50
+ return s
51
+
52
+
53
+ def to_kcd_bytes(db: CanDb) -> bytes:
54
+ if db.bus is None:
55
+ raise ValueError("cannot serialize to KCD without a bus")
56
+ root = etree.Element(_q("NetworkDefinition"), nsmap=_NSMAP)
57
+ doc = db.document
58
+ d = etree.SubElement(root, _q("Document"),
59
+ name=doc.name if doc else "",
60
+ version=doc.version if doc else "",
61
+ author=doc.author if doc else "")
62
+ d.text = doc.name if doc else ""
63
+
64
+ node_id: dict[str, str] = {}
65
+ for idx, node in enumerate(db.nodes, start=1):
66
+ node_id[node.name] = str(idx)
67
+ etree.SubElement(root, _q("Node"), id=str(idx), name=node.name)
68
+
69
+ bus = etree.SubElement(root, _q("Bus"), name=db.bus.name,
70
+ baudrate=str(db.bus.baudrate))
71
+
72
+ for msg in db.messages:
73
+ m = etree.SubElement(bus, _q("Message"), id=f"0x{msg.id:X}",
74
+ length=str(msg.length), name=msg.name)
75
+ if msg.interval is not None:
76
+ m.set("interval", str(msg.interval))
77
+ if msg.doc and msg.doc.kcd_notes:
78
+ etree.SubElement(m, _q("Notes")).text = msg.doc.kcd_notes
79
+ if msg.producers:
80
+ prod = etree.SubElement(m, _q("Producer"))
81
+ for p in msg.producers:
82
+ etree.SubElement(prod, _q("NodeRef"), id=node_id[p])
83
+
84
+ plain = [s for s in msg.signals if s.mux is None]
85
+ selector = next((s for s in msg.signals if s.mux == "multiplexor"), None)
86
+ members = [s for s in msg.signals if isinstance(s.mux, MuxMember)]
87
+
88
+ for sig in plain:
89
+ m.append(_signal_elem(sig, node_id))
90
+
91
+ if selector is not None:
92
+ mux_elem = etree.SubElement(m, _q("Multiplex"))
93
+ _set_attrs(mux_elem, selector)
94
+ by_group: dict[int, list[Signal]] = {}
95
+ for s in members:
96
+ if s.mux.of == selector.name:
97
+ by_group.setdefault(s.mux.group, []).append(s)
98
+ for group, sigs in by_group.items():
99
+ mg = etree.SubElement(mux_elem, _q("MuxGroup"), count=str(group))
100
+ for s in sigs:
101
+ mg.append(_signal_elem(s, node_id))
102
+ if _kcd_notes(selector):
103
+ etree.SubElement(mux_elem, _q("Notes")).text = _kcd_notes(selector)
104
+ if selector.consumers:
105
+ cons = etree.SubElement(mux_elem, _q("Consumer"))
106
+ for c in selector.consumers:
107
+ etree.SubElement(cons, _q("NodeRef"), id=node_id[c])
108
+ etree.SubElement(mux_elem, _q("Value"), type=selector.type,
109
+ slope=repr(selector.slope),
110
+ intercept=repr(selector.intercept))
111
+
112
+ xml = etree.tostring(root, pretty_print=True, xml_declaration=True,
113
+ encoding="UTF-8")
114
+ return xml.replace(b"</Message>\n", b"</Message>\n\n")
115
+
116
+
117
+ def dump_kcd(db: CanDb, path: Path | str) -> None:
118
+ Path(path).write_bytes(to_kcd_bytes(db))
@@ -0,0 +1,17 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import yaml
6
+
7
+ from .model import CanDb
8
+
9
+
10
+ def to_yaml_str(db: CanDb) -> str:
11
+ # exclude_none/exclude_defaults keep the output terse, mirroring authored YAML.
12
+ data = db.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
13
+ return yaml.safe_dump(data, sort_keys=False, default_flow_style=False)
14
+
15
+
16
+ def dump_yaml(db: CanDb, path: Path | str) -> None:
17
+ Path(path).write_text(to_yaml_str(db))
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: can-db-model
3
+ Version: 0.1.0
4
+ Summary: Shared Pydantic model + KCD/YAML loaders for CAN databases
5
+ Project-URL: Documentation, https://sr.ht/~laplace/can-db-model#readme
6
+ Project-URL: Issues, https://sr.ht/~laplace/can-db-model/issues
7
+ Project-URL: Source, https://sr.ht/~laplace/can-db-model
8
+ Author-email: Alexander Krishna-Becker <nabla.becker@mailbox.org>
9
+ License-Expression: LGPL-3.0-only
10
+ Keywords: automotive,can,dbc,kcd,pydantic,yaml
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Programming Language :: Python
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: Implementation :: CPython
18
+ Classifier: Topic :: Software Development :: Embedded Systems
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: cantools
21
+ Requires-Dist: lxml
22
+ Requires-Dist: pydantic>=2
23
+ Requires-Dist: pyyaml
24
+ Description-Content-Type: text/markdown
25
+
26
+ # can-db-model
27
+
28
+ Shared Pydantic model + KCD/YAML loaders and serializers for CAN databases.
29
+ Consumed by can-api-generator and kcd-merger.
@@ -0,0 +1,11 @@
1
+ can_db_model/__about__.py,sha256=kUR5RAFc7HCeiqdlX36dZOHkUI5wI6V_43RpEcD8b-0,22
2
+ can_db_model/__init__.py,sha256=jt-WXAX7fN2SkNJaXuKEg7yfpb0oU9hJJLYPxBfocVE,549
3
+ can_db_model/from_kcd.py,sha256=yLpXMfQSVaKDvEbzvJX0Zv_iUTPOwY8Tmg7SRffHDeg,3958
4
+ can_db_model/from_yaml.py,sha256=f0ZeRrI8bPARvZU04nM_uRWu1rQUQ1db-ziVT8IFJsg,943
5
+ can_db_model/load.py,sha256=y2WFUmpFeuAzN_3W9JTd_5pK6SuT6wuBp5zvIJzRTOg,457
6
+ can_db_model/model.py,sha256=wTg29K-DHyQ3HO7BQsjZHKRfdMaFNCSOj1-jJ0SVtI4,5269
7
+ can_db_model/to_kcd.py,sha256=wWAAoz_2aPjdR5t07BD_l0VUsKwyvfUmClYLAiYWX8M,4443
8
+ can_db_model/to_yaml.py,sha256=4qM-TUAYE4zGS_FgyTQf1ApxaYx9sPhJxL-Njq90e3g,473
9
+ can_db_model-0.1.0.dist-info/METADATA,sha256=iDGSNrN5KLHYhbkkRSaqCbCLSbqCAt1vjJY88SVvaCE,1183
10
+ can_db_model-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
11
+ can_db_model-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any