Skip to content

Commit

Permalink
Merge pull request #18 from bartkl/cli
Browse files Browse the repository at this point in the history
Cli
  • Loading branch information
bartkl authored Apr 7, 2024
2 parents 25b3bdf + 8bfdbf8 commit 5af87a0
Show file tree
Hide file tree
Showing 8 changed files with 271 additions and 41 deletions.
102 changes: 102 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
## Installation
Make sure you have Python (≥ 3.11) and Poetry installed.

Run `poetry install` to have it set up a virtual environment for you with the necessary dependencies installed and configuration taken care of.

## Running `cim2linkml`

#### From within the virtual environment
Activate your virtual environment and you should be able to use the `cim2linkml` script.

```
$ poetry shell
$ cim2linkml --help
# ...
```

#### Using `poetry run`
You can also run the script inside the virtual environment without activating it.

```
$ poetry run cim2linkml --help
# ...
```


### Usage
```
Usage: cim2linkml [OPTIONS] QEA_FILE
Generates LinkML schemas from the supplied Sparx EA QEA database file.
You can specify which packages in the UML model to generate schemas from
using the `--package` parameter, where you provide the fully qualified
package name (e.g. `TC57CIM.IEC61970.Base.Core') of the package to select
it.
If the specified package is a leaf package, a single schema file will be
generated.
If the provided package is a non-leaf package, by default its subpackages
are included and a schema file per package is created. A single schema file
can also be created by passing `--single-schema'. Finally, it's possible to
ignore all subpackages and create a single schema file just for the
specified package alone. To achieve this, pass `--ignore-subpackages'.
Options:
-p, --package TEXT Fully qualified package name. [default: TC57CIM]
--single-schema If true, a single schema is created, a schema per
package otherwise.
--ignore-subpackages If passed, all subpackages of the provided package
are ignored, i.e. only the package itself is
selected.
-o, --output-dir PATH Directory where schemas will be outputted. [default:
schemas]
--help Show this message and exit.
```

### Examples

#### The entire CIM

##### Schema per package
If no package is specified, it defaults to TC57CIM, i.e. the entire CIM. For non-leaf
packages like this one, the default behavior is to generate a schema for each subpackage.

```shell
$ cim2linkml data/cim.qea
```

##### Single schema
If generating a single schema file is desired, this can be done as follows:

```shell
$ cim2linkml data/cim.qea --single-schema
```

#### Leaf package
Leaf peackages by definition don't have subpackages and therefore always become a single schema.

```shell
$ cim2linkml data/cim.qea -p TC57CIM.IEC61970.Base.Wires
```

#### Non-leaf package

##### Including subpackages
By default, when providing a non-leaf package, all subpackages are included and schema files are
created for each package.

```shell
$ cim2linkml data/cim.qea -p TC57CIM.IEC61970
```

If a single schema file is desired, `--single-schema` can be passed.

##### Ignoring subpackages
If only selecting the package itself is desired, i.e. not including subpackages, one can pass `--ignore-subpackages`.
Note that in this case, it is always a single schema file (`--single-schema` is implied).

```shell
$ cim2linkml data/cim.qea -p TC57CIM.IEC61970 --ignore-subpackages
```
28 changes: 0 additions & 28 deletions cim_to_linkml/__init__.py
Original file line number Diff line number Diff line change
@@ -1,28 +0,0 @@
import cProfile
import sqlite3

from cim_to_linkml.generator import LinkMLGenerator
from cim_to_linkml.parser import parse_uml_project
from cim_to_linkml.read import read_uml_project
from cim_to_linkml.writer import init_yaml_serializer, write_schema

init_yaml_serializer()


def main():
db_file = "data/iec61970cim17v40_iec61968cim13v13b_iec62325cim03v17b_CIM100.1.1.1.qea"
with sqlite3.connect(db_file) as conn:
uml_project = parse_uml_project(*read_uml_project(conn))

generator = LinkMLGenerator(uml_project)
for pkg_id in generator.uml_project.packages.by_id:
if pkg_id == 2:
continue # `Model` base package.

schema = generator.gen_schema_for_package(pkg_id)
write_schema(schema)


if __name__ == "__main__":
# cProfile.run("main()", sort="tottime")
main()
9 changes: 5 additions & 4 deletions cim_to_linkml/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,15 +89,16 @@ def _gen_class_with_deps(self, uml_class: uml_model.Class) -> None:
continue
self._gen_class_with_deps(uml_dep_class)

def gen_schema_for_package(self, uml_package_id: uml_model.ObjectID) -> linkml_model.Schema:
def gen_schema_for_package(
self, uml_package_id: uml_model.ObjectID, uml_classes: list[uml_model.Class]
) -> linkml_model.Schema:
uml_package = self.uml_project.packages.by_id[uml_package_id]

# Set or reset state of generator.
# (Re-)initialize generator state.
self.classes: dict[linkml_model.ClassName, linkml_model.Class] = {}
self.enums: dict[linkml_model.EnumName, linkml_model.Enum] = {}

# for uml_class in self.uml_project.classes.by_id.values():
for uml_class in self.uml_project.classes.by_package.get(uml_package_id, []):
for uml_class in uml_classes:
self._gen_class_with_deps(uml_class)

schema = linkml_model.Schema(
Expand Down
130 changes: 130 additions & 0 deletions cim_to_linkml/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import cProfile
import logging
import os
import sqlite3
from itertools import chain
from pathlib import Path

import click

from cim_to_linkml.generator import LinkMLGenerator
from cim_to_linkml.parser import parse_uml_project
from cim_to_linkml.read import read_uml_project
from cim_to_linkml.writer import init_yaml_serializer, write_schema

LOG_FORMAT = "[%(asctime)s] [%(levelname)s] %(message)s"

logger = logging.getLogger(__name__)
logging.basicConfig(format=LOG_FORMAT)


init_yaml_serializer()


@click.command()
@click.argument("cim_db", type=click.Path(exists=True, path_type=Path), nargs=1, metavar="QEA_FILE")
@click.option(
"--package",
"-p",
type=str,
show_default=True,
default="TC57CIM",
help="Fully qualified package name.",
)
@click.option(
"--single-schema",
is_flag=True,
default=False,
show_default=True,
help="If true, a single schema is created, a schema per package otherwise.",
)
@click.option(
"--ignore-subpackages",
is_flag=True,
default=False,
show_default=True,
help="If passed, all subpackages of the provided package are ignored, i.e. only the package itself is selected.",
)
@click.option(
"--output-dir",
"-o",
default=Path("schemas"),
show_default=True,
type=click.Path(path_type=Path),
help="Directory where schemas will be outputted.",
)
def cli(
cim_db,
package,
single_schema,
ignore_subpackages,
output_dir,
):
"""
Generates LinkML schemas from the supplied Sparx EA QEA database file.
You can specify which packages in the UML model to generate schemas from
using the `--package` parameter, where you provide the fully qualified
package name (e.g. `TC57CIM.IEC61970.Base.Core') of the package to select it.
If the specified package is a leaf package, a single schema file will be
generated.
If the provided package is a non-leaf package, by default its subpackages are
included and a schema file per package is created. A single schema file can also
be created by passing `--single-schema'.
Finally, it's possible to ignore all subpackages and create a single schema file
just for the specified package alone. To achieve this, pass `--ignore-subpackages'.
"""

with sqlite3.connect(cim_db) as conn:
uml_project = parse_uml_project(*read_uml_project(conn))

try:
uml_package = uml_project.packages.by_qualified_name[package]
except KeyError:
click.echo(f"Ignoring unknown package: `{package}'.", err=True)
raise SystemExit(1)

if uml_project.packages.is_leaf_package(package):
single_schema = True
ignore_subpackages = True

if ignore_subpackages:
uml_packages = [uml_package]
single_schema = True
else:
uml_packages = [p for qname, p in uml_project.packages.by_qualified_name.items() if qname.startswith(package)]

generator = LinkMLGenerator(uml_project)
os.makedirs(output_dir, exist_ok=True)

if single_schema:
uml_classes = list(
chain.from_iterable(
uml_class for p in uml_packages if (uml_class := uml_project.classes.by_package.get(p.id))
)
)
schema = generator.gen_schema_for_package(uml_package.id, uml_classes)

schema_path = os.path.join(output_dir, package) + ".yml"
write_schema(schema, schema_path)
else:
for uml_package in uml_packages:
uml_classes = uml_project.classes.by_package.get(uml_package.id, [])
schema = generator.gen_schema_for_package(uml_package.id, uml_classes)

qname = uml_project.packages.get_qualified_name(uml_package.id)
package_path = qname.split(".")
dir_path = os.path.join(output_dir, os.path.sep.join(package_path[:-1]))
file_name = package_path[-1] + ".yml"
out_file = os.path.join(dir_path, file_name)
os.makedirs(dir_path, exist_ok=True)
write_schema(schema, out_file)


if __name__ == "__main__":
# cProfile.run("main()", sort="tottime")
cli.main()
3 changes: 3 additions & 0 deletions cim_to_linkml/uml_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,9 @@ def by_qualified_name(self):
def get_qualified_name(self, package_id):
return ".".join(self._get_package_path(package_id))

def is_leaf_package(self, qname: str):
return len([qn for qn in self.by_qualified_name if qn.startswith(qname)]) == 1

def _get_package_path(self, start_pkg_id, package_path=None):
if package_path is None:
package_path = []
Expand Down
7 changes: 0 additions & 7 deletions cim_to_linkml/writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,5 @@ def represent_linkml_slot(dumper, data):


def write_schema(schema: linkml_model.Schema, out_file: Optional[os.PathLike | str] = None) -> None:
if out_file is None:
path_parts = schema.name.split(".")
dir_path = os.path.join("schemas", os.path.sep.join(path_parts[:-1]))
file_name = f"{path_parts[-1]}.yml"
out_file = os.path.join(dir_path, file_name)
os.makedirs(dir_path, exist_ok=True)

with open(out_file, "w") as f:
yaml.dump(schema, f, indent=2, default_flow_style=False, sort_keys=False)
27 changes: 26 additions & 1 deletion poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "cim-to-linkml"
version = "0.9.0"
version = "2.0.0"
description = ""
authors = ["Bart Kleijngeld <bartkl@gmail.com>"]
readme = "README.md"
Expand All @@ -11,11 +11,15 @@ line-length = 120
[tool.poetry.dependencies]
python = "^3.11"
pyyaml = "^6.0.1"
click = "^8.1.7"

[tool.poetry.group.dev.dependencies]
pyright = "^1.1.356"
isort = "^5.13.2"

[tool.poetry.scripts]
cim2linkml = "cim_to_linkml.main:cli"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

0 comments on commit 5af87a0

Please sign in to comment.