Skip to content

Commit da3a699

Browse files
committed
feat: Add a class diagram exporter
This is effectively the inverse operation of the existing json2capella, i.e. capella2json.
1 parent 6541083 commit da3a699

File tree

3 files changed

+213
-1
lines changed

3 files changed

+213
-1
lines changed

json2capella/datatypes.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,9 @@ class Struct(_BaseModel):
3838

3939
@p.field_validator("extends")
4040
@classmethod
41-
def extends_is_valid_dotted_name(cls, value: str) -> str:
41+
def extends_is_valid_dotted_name(cls, value: str | None) -> str | None:
42+
if value is None:
43+
return value
4244
numdots = sum(1 for char in value if char == ".")
4345
if numdots not in (0, 1):
4446
raise ValueError(

json2capella/export.py

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# Copyright DB InfraGO AG and contributors
2+
# SPDX-License-Identifier: Apache-2.0
3+
"""The json2capella exporter CLI, i.e. capella2json."""
4+
5+
from __future__ import annotations
6+
7+
import logging
8+
import typing as t
9+
10+
import capellambse
11+
import click
12+
from capellambse import cli_helpers
13+
from capellambse.metamodel import information
14+
15+
from . import datatypes
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
@click.command()
21+
@click.option(
22+
"-m",
23+
"--model",
24+
required=True,
25+
type=cli_helpers.ModelCLI(),
26+
help="Path to the Capella model.",
27+
)
28+
@click.option(
29+
"-p",
30+
"--package",
31+
"package_id",
32+
help="ID or name of the DataPkg to export.",
33+
)
34+
@click.option(
35+
"-l",
36+
"--list",
37+
"list_packages",
38+
is_flag=True,
39+
help="List all data packages found in the model.",
40+
)
41+
@click.option(
42+
"-o",
43+
"--output",
44+
default="-",
45+
type=click.File("w", atomic=True),
46+
)
47+
@click.option(
48+
"--indent",
49+
default=None,
50+
type=click.IntRange(0),
51+
help="Pretty-print the JSON output, indenting by INT spaces per level.",
52+
)
53+
def main(
54+
*,
55+
model: capellambse.MelodyModel,
56+
package_id: str,
57+
list_packages: bool,
58+
output: t.IO[str],
59+
indent: int | None,
60+
) -> None:
61+
if list_packages:
62+
print("The following data packages were found in the model:")
63+
for i in model.search("DataPkg"):
64+
print(" ", i._short_repr_())
65+
return
66+
67+
if not package_id:
68+
raise click.UsageError(
69+
"--package is required (use --list to find a package)"
70+
)
71+
72+
package_obj = _find_package(model, package_id)
73+
package = _convert_package(package_obj)
74+
dump = package.model_dump_json(indent=indent, exclude_defaults=True)
75+
with output:
76+
output.write(dump)
77+
78+
79+
def _find_package(
80+
model: capellambse.MelodyModel, package_id: str, /
81+
) -> information.DataPkg:
82+
try:
83+
obj = model.by_uuid(package_id)
84+
except KeyError:
85+
pass
86+
else:
87+
if not isinstance(obj, information.DataPkg):
88+
raise click.UsageError(
89+
f"Expected a DataPkg at ID {package_id!r},"
90+
f" but found a {type(obj).__name__} instead"
91+
)
92+
return obj
93+
94+
packages = model.search(information.DataPkg).by_name(
95+
package_id, single=False
96+
)
97+
if len(packages) < 1:
98+
raise click.UsageError(
99+
f"Couldn't find a DataPkg with ID or name {package_id!r}"
100+
)
101+
if len(packages) > 1:
102+
pkg_strings = []
103+
for i in packages:
104+
try:
105+
p = i.parent
106+
except ValueError:
107+
pkg_strings.append(f" {i.uuid!r} (orphaned)")
108+
else:
109+
pkg_strings.append(f" {i.uuid!r} in {p._short_repr_()}")
110+
raise click.UsageError(
111+
f"Found more than one DataPkg with name {package_id!r}:\n"
112+
+ "\n".join(pkg_strings)
113+
)
114+
(obj,) = packages
115+
assert isinstance(obj, information.DataPkg)
116+
return obj
117+
118+
119+
def _convert_package(obj: information.DataPkg, /) -> datatypes.Package:
120+
return datatypes.Package(
121+
name=obj.name,
122+
info=obj.description,
123+
subPackages=[_convert_package(i) for i in obj.packages], # type: ignore[call-arg]
124+
structs=[_convert_class(i) for i in obj.classes],
125+
enums=[_convert_enum(i) for i in obj.enumerations],
126+
prefix=obj.uuid,
127+
)
128+
129+
130+
def _convert_class(obj: information.Class, /) -> datatypes.Struct:
131+
if obj.super is None:
132+
extends = None
133+
else:
134+
extends = ""
135+
return datatypes.Struct(
136+
name=obj.name,
137+
info=obj.description,
138+
extends=extends,
139+
attrs=[_convert_attr(i) for i in obj.properties],
140+
)
141+
142+
143+
def _convert_attr(obj: information.Property, /) -> datatypes.StructAttrs:
144+
min = 0
145+
max = None
146+
if (c := obj.max_card) and (v := c.value) and v != "*":
147+
try:
148+
max = int(v)
149+
except ValueError:
150+
logger.warning(
151+
"Cannot convert max_card value %r of %s to int, ignoring",
152+
v,
153+
obj._short_repr_(),
154+
)
155+
if (c := obj.min_card) and (v := c.value):
156+
try:
157+
min = int(v)
158+
except ValueError:
159+
logger.warning(
160+
"Cannot convert min_card value %r of %s to int, ignoring",
161+
v,
162+
obj._short_repr_(),
163+
)
164+
165+
if (min, max) == (0, None) or min == max:
166+
if max is None:
167+
mult = "*"
168+
else:
169+
mult = str(max)
170+
else:
171+
mult = f"{min}..{max or '*'}"
172+
173+
if obj.type:
174+
typename = obj.type.name
175+
else:
176+
logger.warning(
177+
"No type set, falling back to 'string' for %s",
178+
obj._short_repr_(),
179+
)
180+
typename = "string"
181+
182+
return datatypes.StructAttrs(
183+
name=obj.name,
184+
info=obj.description,
185+
multiplicity=mult,
186+
data_type=typename,
187+
)
188+
189+
190+
def _convert_enum(obj: information.datatype.Enumeration, /) -> datatypes.Enum:
191+
return datatypes.Enum(
192+
name=obj.name,
193+
info=obj.description,
194+
enumLiterals=[ # type: ignore[call-arg]
195+
datatypes.EnumLiteral(
196+
intId=i, # type: ignore[call-arg]
197+
name=o.name,
198+
info=o.description,
199+
)
200+
for i, o in enumerate(obj.literals)
201+
],
202+
)
203+
204+
205+
if __name__ == "__main__":
206+
main()

pyproject.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ dependencies = [
3838
Homepage = "https://github.com/DSD-DBS/json2capella"
3939
Documentation = "https://dsd-dbs.github.io/json2capella"
4040

41+
[project.scripts]
42+
json2capella = "json2capella.__main__:main"
43+
capella2json = "json2capella.export:main"
44+
4145
[dependency-groups]
4246
dev = [
4347
"coverage>=7.9.1",

0 commit comments

Comments
 (0)