diff --git a/CIMgen.py b/CIMgen.py index 7cb924eb..b759e0ea 100644 --- a/CIMgen.py +++ b/CIMgen.py @@ -461,7 +461,7 @@ def _write_python_files(elem_dict, langPack, outputPath, version): class_details = { "attributes": _find_multiple_attributes(elem_dict[class_name].attributes()), - "ClassLocation": langPack.get_class_location(class_name, elem_dict, outputPath), + "class_location": langPack.get_class_location(class_name, elem_dict, outputPath), "class_name": class_name, "class_origin": elem_dict[class_name].origins(), "instances": elem_dict[class_name].instances(), diff --git a/modernpython/langPack.py b/modernpython/langPack.py index 14a2f0db..27c6b513 100644 --- a/modernpython/langPack.py +++ b/modernpython/langPack.py @@ -1,9 +1,7 @@ -import glob import logging import os import re -import sys -import textwrap +from distutils.dir_util import copy_tree from pathlib import Path import chevron @@ -16,15 +14,19 @@ # cgmes_profile_info details which uri belongs in each profile. # We don't use that here because we aren't creating the header # data for the separate profiles. -def setup(version_path, cgmes_profile_info): # NOSONAR - if not os.path.exists(version_path): - os.makedirs(version_path) - _create_init(version_path) - _create_base(version_path) +def setup(version_path, cgmes_profile_info): # NOSONAR + # version_path is actually the output_path + + # Add all hardcoded utils and create parent dir + source_dir=Path(__file__).parent/"utils" + dest_dir=Path(version_path)/"utils" + + copy_tree(str(source_dir), str(dest_dir)) + def location(version): - return "cimpy." + version + ".Base" + return "..utils.base" base = {"base_class": "Base", "class_location": location} @@ -33,14 +35,7 @@ def location(version): def get_class_location(class_name, class_map, version): - # Check if the current class has a parent class - if class_map[class_name].superClass(): - if class_map[class_name].superClass() in class_map: - return "cimpy." + version + "." + class_map[class_name].superClass() - elif class_map[class_name].superClass() == "Base" or class_map[class_name].superClass() == None: - return location(version) - else: - return location(version) + return f".{class_map[class_name].superClass()}" partials = {} @@ -90,9 +85,14 @@ def set_float_classes(new_float_classes): def run_template(version_path, class_details): for template_info in template_files: - class_file = os.path.join(version_path, class_details["class_name"] + template_info["ext"]) - if not os.path.exists(class_file): - with open(class_file, "w", encoding="utf-8") as file: + + resource_file = Path(os.path.join(version_path, "resources", class_details["class_name"] + template_info["ext"])) + if not resource_file.exists() : + if not (parent:=resource_file.parent).exists(): + parent.mkdir() + + with open(resource_file, "w", encoding="utf-8") as file: + template_path = os.path.join(os.getcwd(), "modernpython/templates", template_info["filename"]) class_details["setDefault"] = _set_default class_details["setType"] = _set_type @@ -106,51 +106,38 @@ def run_template(version_path, class_details): file.write(output) -def _create_init(path): - init_file = path + "/__init__.py" - - with open(init_file, "w", encoding="utf-8") as init: - init.write("# pylint: disable=too-many-lines,missing-module-docstring\n") - - -# creates the Base class file, all classes inherit from this class -def _create_base(path): - # TODO: Check export priority of OP en SC, see Profile class - base_path = path + "/Base.py" - with open(Path(__file__).parent / "Base.py", encoding="utf-8") as src, open( - base_path, "w", encoding="utf-8" - ) as dst: - dst.write(src.read()) - def resolve_headers(dest: str, version: str): """Add all classes in __init__.py""" - if match := re.search(r"(?P\d+_\d+_\d+)", version): # NOSONAR + + if match := re.search(r"(?P\d+_\d+_\d+)", version): # NOSONAR version_number = match.group("num").replace("_", ".") else: raise ValueError(f"Cannot parse {version} to extract a number.") - dest = Path(dest) + dest = Path(dest)/"resources" with open(dest / "__init__.py", "a", encoding="utf-8") as header_file: - _all = [] - for include_name in sorted(dest.glob("*.py")): - stem = include_name.stem - if stem == "__init__": - continue - _all.append(stem) - header_file.write(f"from .{stem} import {stem}\n") - + header_file.write("# pylint: disable=too-many-lines,missing-module-docstring\n") header_file.write(f"CGMES_VERSION='{version_number}'\n") - _all.append("CGMES_VERSION") - - header_file.write( - "\n".join( - [ - "# This is not needed per se, but by referencing all imports", - "# this prevents a potential autoflake from cleaning up the whole file.", - "# FYA, if __all__ is present, only what's in there will be import with a import *", - "", - ] - ) - ) - header_file.write(f"__all__={_all}") + + # # Under this, add all imports in init. Disabled becasue loading 600 unneeded classes is slow. + # _all = ["CGMES_VERSION"] + + # for include_name in sorted(dest.glob("*.py")): + # stem = include_name.stem + # if stem in[ "__init__", "Base"]: + # continue + # _all.append(stem) + # header_file.write(f"from .{stem} import {stem}\n") + + # header_file.write( + # "\n".join( + # [ + # "# This is not needed per se, but by referencing all imports", + # "# this prevents a potential autoflake from cleaning up the whole file.", + # "# FYA, if __all__ is present, only what's in there will be import with a import *", + # "", + # ] + # ) + # ) + # header_file.write(f"__all__={_all}") diff --git a/modernpython/templates/cimpy_class_template.mustache b/modernpython/templates/cimpy_class_template.mustache index 94b979f6..cf9b32d5 100644 --- a/modernpython/templates/cimpy_class_template.mustache +++ b/modernpython/templates/cimpy_class_template.mustache @@ -6,8 +6,10 @@ from functools import cached_property from typing import Optional from pydantic import Field from pydantic.dataclasses import dataclass -from .Base import DataclassConfig, Profile -from .{{sub_class_of}} import {{sub_class_of}} +from ..utils.dataclassconfig import DataclassConfig +from ..utils.profile import BaseProfile, Profile + +from {{class_location}} import {{sub_class_of}} @dataclass(config=DataclassConfig) class {{class_name}}({{sub_class_of}}): @@ -31,7 +33,7 @@ class {{class_name}}({{sub_class_of}}): @cached_property - def possible_profiles(self)->set[Profile]: + def possible_profiles(self)->set[BaseProfile]: """ A resource can be used by multiple profiles. This is the set of profiles where this element can be found. diff --git a/modernpython/utils/__init__.py b/modernpython/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/modernpython/Base.py b/modernpython/utils/base.py similarity index 53% rename from modernpython/Base.py rename to modernpython/utils/base.py index c2784222..c307b33b 100644 --- a/modernpython/Base.py +++ b/modernpython/utils/base.py @@ -1,104 +1,24 @@ -# We follow the CIM naming convention, not python. -# pylint: disable=invalid-name - -""" -Parent element of all CGMES elements -""" +# Drop in dataclass replacement, allowing easier json dump and validation in the future. import importlib from dataclasses import Field, fields -from enum import Enum -from functools import cache, cached_property -from typing import Any, TypeAlias +from functools import cached_property +from typing import Any, TypeAlias, TypedDict -# Drop in dataclass replacement, allowing easier json dump and validation in the future. +from pycgmes.utils.constants import NAMESPACES from pydantic.dataclasses import dataclass - -class Profile(Enum): - """ - Enum containing all CGMES profiles and their export priority. - todo: enums are ordered, so we can have a short->long enum without explicit prio - """ - - EQ = 0 - SSH = 1 - TP = 2 - SV = 3 - DY = 4 - OP = 5 - SC = 6 - GL = 7 - # DI = 8 # Initially mentioned but does not seem used? - DL = 9 - TPBD = 10 - EQBD = 11 - - @cached_property - def long_name(self): - """From the short name, return the long name of the profile.""" - return self._short_to_long()[self.name] - - @classmethod - def from_long_name(cls, long_name): - """From the long name, return the short name of the profile.""" - return cls[cls._long_to_short()[long_name]] - - @classmethod - @cache - def _short_to_long(cls) -> dict[str, str]: - """Returns the long name from a short name""" - return { - "DL": "DiagramLayout", - # "DI": "DiagramLayout", - "DY": "Dynamics", - "EQ": "Equipment", - "EQBD": "EquipmentBoundary", # Not too sure about that one - "GL": "GeographicalLocation", - "OP": "Operation", - "SC": "ShortCircuit", - "SV": "StateVariables", - "SSH": "SteadyStateHypothesis", - "TP": "Topology", - "TPBD": "TopologyBoundary", # Not too sure about that one - } - - @classmethod - @cache - def _long_to_short(cls) -> dict[str, str]: - """Returns the short name from a long name""" - return {_long: _short for _short, _long in cls._short_to_long().items()} - - -class DataclassConfig: # pylint: disable=too-few-public-methods - """ - Used to configure pydantic dataclasses. - - See doc at - https://docs.pydantic.dev/latest/usage/model_config/#options - """ - - # By default with pydantic extra arguments given to a dataclass are silently ignored. - # This matches the default behaviour by failing noisily. - extra = "forbid" - - -# Default namespaces used by CGMES. -NAMESPACES = { - "cim": "http://iec.ch/TC57/2013/CIM-schema-cim16#", # NOSONAR - "entsoe": "http://entsoe.eu/CIM/SchemaExtension/3/1#", # NOSONAR - "md": "http://iec.ch/TC57/61970-552/ModelDescription/1#", # NOSONAR - "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", # NOSONAR -} +from .dataclassconfig import DataclassConfig +from .profile import BaseProfile @dataclass(config=DataclassConfig) class Base: """ - Base Class for CIM. + Base Class for pylint . """ @cached_property - def possible_profiles(self) -> set[Profile]: + def possible_profiles(self) -> set[BaseProfile]: raise NotImplementedError("Method not implemented because not relevant in Base.") @staticmethod @@ -121,9 +41,10 @@ def to_dict(self) -> dict[str, "CgmesAttributeTypes"]: Returns the class as dict, with: - only public attributes - adding __class__ with the classname (for deserialisation) + """ attrs = {f.name: getattr(self, f.name) for f in fields(self)} - attrs["__class__"] = self.resource_name + attrs["__class__"] = self.apparent_name() return attrs @cached_property @@ -131,7 +52,23 @@ def resource_name(self) -> str: """Returns the resource type.""" return self.__class__.__name__ - def cgmes_attribute_names_in_profile(self, profile: Profile | None) -> set[Field]: + @cached_property + def namespace(self) -> str: + """Returns the namespace. By default, the namespace is the cim namespace for all resources. + Custom resources can override this. + """ + return NAMESPACES["cim"] + + @classmethod # From python 3.11, you cannot wrap @classmethod in @property anymore. + def apparent_name(cls) -> str: + """ + If you create your own custom attributes by subclassing a resource, + but you do not want the name of your new subclass to appear, you can force the apparent name by + overriding this method. + """ + return cls.__name__ + + def cgmes_attribute_names_in_profile(self, profile: BaseProfile | None) -> set[Field]: """ Returns all fields accross the parent tree which are in the profile in parameter. @@ -156,9 +93,9 @@ def cgmes_attribute_names_in_profile(self, profile: Profile | None) -> set[Field if f.name != "mRID" } - def cgmes_attributes_in_profile(self, profile: Profile | None) -> dict[str, "CgmesAttributeTypes"]: + def cgmes_attributes_in_profile(self, profile: BaseProfile | None) -> dict[str, "CgmesAttribute"]: """ - Returns all attribute values as a dict: fully qualified name => value. + Returns all attribute values as a dict: fully qualified name => CgmesAttribute. Fully qualified names is in the form class_name.attribute_name, where class_name is the (possibly parent) class where the attribute is defined. @@ -167,26 +104,58 @@ def cgmes_attributes_in_profile(self, profile: Profile | None) -> dict[str, "Cgm with thus the parent class included in the attribute name. """ # What will be returned, has the qualname as key... - qual_attrs: dict[str, "CgmesAttributeTypes"] = {} - # .. but we check existence with the unqualified (short) name. + qual_attrs: dict[str, "CgmesAttribute"] = {} + # ... but we check existence with the unqualified (short) name. seen_attrs = set() + # mro contains itself (so parent might be a misnomer) and object, removed with the [:-1]. for parent in reversed(self.__class__.__mro__[:-1]): for f in fields(parent): shortname = f.name - qualname = f"{parent.__name__}.{shortname}" + qualname = f"{parent.apparent_name()}.{shortname}" # type: ignore if f not in self.cgmes_attribute_names_in_profile(profile) or shortname in seen_attrs: # Wrong profile or already found from a parent. continue else: - qual_attrs[qualname] = getattr(self, shortname) + # Namespace finding + # "class namespace" means the first namespace defined in the inheritance tree. + # This can go up to Base, which will give the default cim NS. + if (extra := getattr(f.default, "extra", None)) is None: + # The attribute does not have extra metadata. It might be a custom atttribute + # without it, or a base type (int...). + # Use the class namespace. + namespace = self.namespace + elif (attr_ns := extra.get("namespace", None)) is None: + # The attribute has some extras, but not namespace. + # Use the class namespace. + namespace = self.namespace + else: + # The attribute has an explicit namesapce + namespace = attr_ns + + qual_attrs[qualname] = CgmesAttribute( + value=getattr(self, shortname), + namespace=namespace, + ) seen_attrs.add(shortname) return qual_attrs def __str__(self) -> str: """Returns the string representation of this resource.""" - return "\n".join([f"{k}={v}" for k, v in self.to_dict().items()]) + return "\n".join([f"{k}={v}" for k, v in sorted(self.to_dict().items())]) CgmesAttributeTypes: TypeAlias = str | int | float | Base | list | None + + +class CgmesAttribute(TypedDict): + """ + Describes a CGMES attribute: its value and namespace. + """ + + # Actual value + value: CgmesAttributeTypes + # The default will be None. Only custom attributes might have something different, given as metadata. + # See readme for more information. + namespace: str | None diff --git a/modernpython/utils/constants.py b/modernpython/utils/constants.py new file mode 100644 index 00000000..aedb19d5 --- /dev/null +++ b/modernpython/utils/constants.py @@ -0,0 +1,8 @@ +# Default namespaces used by CGMES. +NAMESPACES = { # Those are strings, not real addresses, hence the NOSONAR. + "cim": "http://iec.ch/TC57/CIM100#", # NOSONAR + "entsoe": "http://entsoe.eu/CIM/SchemaExtension/3/1#", # NOSONAR + "md": "http://iec.ch/TC57/61970-552/ModelDescription/1#", # NOSONAR + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", # NOSONAR + "xsd": "http://www.w3.org/2001/XMLSchema#", # NOSONAR +} diff --git a/modernpython/utils/dataclassconfig.py b/modernpython/utils/dataclassconfig.py new file mode 100644 index 00000000..961f0d8a --- /dev/null +++ b/modernpython/utils/dataclassconfig.py @@ -0,0 +1,11 @@ +class DataclassConfig: # pylint: disable=too-few-public-methods + """ + Used to configure pydantic dataclasses. + + See doc at + https://docs.pydantic.dev/latest/usage/model_config/#options + """ + + # By default, with pydantic extra arguments given to a dataclass are silently ignored. + # This matches the default behaviour by failing noisily. + extra = "forbid" diff --git a/modernpython/utils/profile.py b/modernpython/utils/profile.py new file mode 100644 index 00000000..fbccb72e --- /dev/null +++ b/modernpython/utils/profile.py @@ -0,0 +1,36 @@ +from enum import Enum +from functools import cached_property + + +class BaseProfile(str, Enum): + """ + Profile parent. Use it if you need your own profiles. + + All pycgmes objects requiring a Profile are actually asking for a `BaseProfile`. As + Enum with fields cannot be inherited or composed, just create your own CustomProfile without + trying to extend Profile. It will work. + """ + + @cached_property + def long_name(self) -> str: + """Return the long name of the profile.""" + return self.value + + +class Profile(BaseProfile): + """ + Enum containing all CGMES profiles and their export priority. + """ + + # DI= "DiagramLayout" # Not too sure about that one + DL = "DiagramLayout" + DY = "Dynamics" + EQ = "Equipment" + EQBD = "EquipmentBoundary" # Not too sure about that one + GL = "GeographicalLocation" + OP = "Operation" + SC = "ShortCircuit" + SSH = "SteadyStateHypothesis" + SV = "StateVariables" + TP = "Topology" + TPBD = "TopologyBoundary" # Not too sure about that one