From 0378057f98e90d243d3060c9e73c22d63af6dd51 Mon Sep 17 00:00:00 2001 From: Guillaume Roger Date: Wed, 20 Sep 2023 11:21:52 +0200 Subject: [PATCH 1/6] Helpers for custom attributes Signed-off-by: Guillaume Roger --- modernpython/Base.py | 12 +++++++++++- modernpython/langPack.py | 9 +++------ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/modernpython/Base.py b/modernpython/Base.py index c2784222..023ec99a 100644 --- a/modernpython/Base.py +++ b/modernpython/Base.py @@ -131,6 +131,15 @@ def resource_name(self) -> str: """Returns the resource type.""" return self.__class__.__name__ + @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 + subclassing this method, keeping the @classmethod decorator. + """ + return cls.__name__ + def cgmes_attribute_names_in_profile(self, profile: Profile | None) -> set[Field]: """ Returns all fields accross the parent tree which are in the profile in parameter. @@ -171,10 +180,11 @@ def cgmes_attributes_in_profile(self, profile: Profile | None) -> dict[str, "Cgm # .. but we check existence with the unqualified (short) name. seen_attrs = set() + # mro contains itself (so parent might be a misnomer) and object, removed wit 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}" 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 diff --git a/modernpython/langPack.py b/modernpython/langPack.py index 14a2f0db..91aaeea3 100644 --- a/modernpython/langPack.py +++ b/modernpython/langPack.py @@ -1,9 +1,6 @@ -import glob import logging import os import re -import sys -import textwrap from pathlib import Path import chevron @@ -16,7 +13,7 @@ # 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 +def setup(version_path, cgmes_profile_info): # NOSONAR if not os.path.exists(version_path): os.makedirs(version_path) _create_init(version_path) @@ -37,7 +34,7 @@ def get_class_location(class_name, class_map, version): 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: + elif class_map[class_name].superClass() == "Base" or class_map[class_name].superClass() is None: return location(version) else: return location(version) @@ -125,7 +122,7 @@ def _create_base(path): 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.") From d6e174e20cafd427044aa2f8869167f6f6589c65 Mon Sep 17 00:00:00 2001 From: Guillaume Roger Date: Thu, 21 Sep 2023 13:02:27 +0200 Subject: [PATCH 2/6] Modernpython: Rationalise non-resource files/modules Signed-off-by: Guillaume Roger --- CIMgen.py | 2 +- modernpython/langPack.py | 98 ++++++------- .../templates/cimpy_class_template.mustache | 8 +- modernpython/utils/__init__.py | 0 modernpython/utils/base.py | 138 ++++++++++++++++++ modernpython/utils/constants.py | 7 + modernpython/utils/dataclassconfig.py | 12 ++ modernpython/utils/profile.py | 34 +++++ 8 files changed, 241 insertions(+), 58 deletions(-) create mode 100644 modernpython/utils/__init__.py create mode 100644 modernpython/utils/base.py create mode 100644 modernpython/utils/constants.py create mode 100644 modernpython/utils/dataclassconfig.py create mode 100644 modernpython/utils/profile.py 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 91aaeea3..27c6b513 100644 --- a/modernpython/langPack.py +++ b/modernpython/langPack.py @@ -1,6 +1,7 @@ import logging import os import re +from distutils.dir_util import copy_tree from pathlib import Path import chevron @@ -14,14 +15,18 @@ # 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) + # 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} @@ -30,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() is None: - return location(version) - else: - return location(version) + return f".{class_map[class_name].superClass()}" partials = {} @@ -87,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 @@ -103,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 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/utils/base.py b/modernpython/utils/base.py new file mode 100644 index 00000000..84aff3ba --- /dev/null +++ b/modernpython/utils/base.py @@ -0,0 +1,138 @@ +# Drop in dataclass replacement, allowing easier json dump and validation in the future. +import importlib +from dataclasses import Field, fields +from functools import cached_property +from typing import Any, TypeAlias, TypedDict + +from pydantic.dataclasses import dataclass + +from .dataclassconfig import DataclassConfig +from .profile import BaseProfile + + +@dataclass(config=DataclassConfig) +class Base: + """ + Base Class for pylint . + """ + + @cached_property + def possible_profiles(self) -> set[BaseProfile]: + raise NotImplementedError("Method not implemented because not relevant in Base.") + + @staticmethod + def parse_json_as(attrs: dict[str, Any]) -> "Base": + """ + Given a json, returns the original object. + """ + subclass: str = attrs["__class__"] + + # We want all attributes *except* __class__, and I do not want to modify + # the dict in params with del() or .pop() + data_attrs = {k: v for k, v in attrs.items() if k != "__class__"} + + mod = importlib.import_module(f".{subclass}", package="pycgmes.resources") + # Works because the module and the class have the same name. + return getattr(mod, subclass)(**data_attrs) + + 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 + return attrs + + @cached_property + def resource_name(self) -> str: + """Returns the resource type.""" + return self.__class__.__name__ + + @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. + + Mostly useful during export to find all the attributes relevant to one profile only. + + mRID will not be present as a resource attribute in the rdf, it will appear in the id of a resource, + so is skipped. For instance + + + blah + {here the mRID will not appear} + + + If profile is None, returns all. + """ + return { + f + for f in fields(self) + # The field is defined as a pydantic.Field, not a dataclass.field, + # so access to metadata is a tad different. Furthermore, mypy is confused by extra. + if (profile is None or profile in f.default.extra["in_profiles"]) # type: ignore[union-attr] + if f.name != "mRID" + } + + def cgmes_attributes_in_profile(self, profile: BaseProfile | None) -> dict[str, "CgmesAttribute"]: + """ + 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. + + This is used mostly in export, where the attributes need to be written in the form: + 3022308-EL-M01-145-SC3 + with thus the parent class included in the attribute name. + """ + # What will be returned, has the qualname as key... + 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.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] = CgmesAttribute( + value=getattr(self, shortname), + # base types (e.g. int) do not have extras + namespace=extra.get("namespace", None) + if (extra := getattr(f.default, "extra", None)) + else None, + ) + 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()]) +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..742771ce --- /dev/null +++ b/modernpython/utils/constants.py @@ -0,0 +1,7 @@ +# Default namespaces used by CGMES. +NAMESPACES = { + "cim": "http://iec.ch/TC57/2013/CIM-schema-cim16#", + "entsoe": "http://entsoe.eu/CIM/SchemaExtension/3/1#", + "md": "http://iec.ch/TC57/61970-552/ModelDescription/1#", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", +} diff --git a/modernpython/utils/dataclassconfig.py b/modernpython/utils/dataclassconfig.py new file mode 100644 index 00000000..024840a6 --- /dev/null +++ b/modernpython/utils/dataclassconfig.py @@ -0,0 +1,12 @@ +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" + defer_build = True diff --git a/modernpython/utils/profile.py b/modernpython/utils/profile.py new file mode 100644 index 00000000..cd938b18 --- /dev/null +++ b/modernpython/utils/profile.py @@ -0,0 +1,34 @@ +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 From 02248cd0a8937a0bdbd2091913e81d69d2af6623 Mon Sep 17 00:00:00 2001 From: Guillaume Roger Date: Fri, 22 Sep 2023 17:34:43 +0200 Subject: [PATCH 3/6] make the resource module callable. Signed-off-by: Guillaume Roger --- .../templates/cimpy_class_template.mustache | 17 ++++++++++++++++- modernpython/utils/base.py | 4 ++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/modernpython/templates/cimpy_class_template.mustache b/modernpython/templates/cimpy_class_template.mustache index cf9b32d5..a639c78c 100644 --- a/modernpython/templates/cimpy_class_template.mustache +++ b/modernpython/templates/cimpy_class_template.mustache @@ -2,6 +2,9 @@ Generated from the CGMES 3 files via cimgen: https://github.com/sogno-platform/cimgen """ +import sys +from types import ModuleType + from functools import cached_property from typing import Optional from pydantic import Field @@ -12,7 +15,7 @@ from ..utils.profile import BaseProfile, Profile from {{class_location}} import {{sub_class_of}} @dataclass(config=DataclassConfig) -class {{class_name}}({{sub_class_of}}): +class {{class_name}}({{sub_class_of}}, ModuleType): """ {{{wrapped_class_comment}}} @@ -20,6 +23,9 @@ class {{class_name}}({{sub_class_of}}): {{label}}: {{{wrapped_comment}}} {{/attributes}} """ + def __call__(self, *args, **kwargs): + # Dark magic - see last lines of the file. + return {{class_name}}(*args, **kwargs) {{#attributes}} {{^isAssociationUsed}}# *Association not used* @@ -39,3 +45,12 @@ class {{class_name}}({{sub_class_of}}): where this element can be found. """ return { {{#class_origin}}Profile.{{origin}}, {{/class_origin}} } + +# This + inheriting from ModuleType + __call__: +# makes: +# "import {{class_name}}" +# work as well as +# "from {{class_name}} import {{class_name}}". +# You would get a typechecker "not callable" error, but this might be useful for +# backward compatibility. +sys.modules[__name__].__class__ = {{class_name}} \ No newline at end of file diff --git a/modernpython/utils/base.py b/modernpython/utils/base.py index 84aff3ba..3f1cd85b 100644 --- a/modernpython/utils/base.py +++ b/modernpython/utils/base.py @@ -43,7 +43,7 @@ def to_dict(self) -> dict[str, "CgmesAttributeTypes"]: """ 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 @@ -122,7 +122,7 @@ def cgmes_attributes_in_profile(self, profile: BaseProfile | None) -> dict[str, 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 From cc8a46d396d7ef991ed9351dde868fd1085eb473 Mon Sep 17 00:00:00 2001 From: Guillaume Roger Date: Mon, 25 Sep 2023 08:08:30 +0200 Subject: [PATCH 4/6] Remove fun but overkill callable module shortcut Signed-off-by: Guillaume Roger --- .../templates/cimpy_class_template.mustache | 17 +---------------- modernpython/utils/dataclassconfig.py | 1 - 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/modernpython/templates/cimpy_class_template.mustache b/modernpython/templates/cimpy_class_template.mustache index a639c78c..cf9b32d5 100644 --- a/modernpython/templates/cimpy_class_template.mustache +++ b/modernpython/templates/cimpy_class_template.mustache @@ -2,9 +2,6 @@ Generated from the CGMES 3 files via cimgen: https://github.com/sogno-platform/cimgen """ -import sys -from types import ModuleType - from functools import cached_property from typing import Optional from pydantic import Field @@ -15,7 +12,7 @@ from ..utils.profile import BaseProfile, Profile from {{class_location}} import {{sub_class_of}} @dataclass(config=DataclassConfig) -class {{class_name}}({{sub_class_of}}, ModuleType): +class {{class_name}}({{sub_class_of}}): """ {{{wrapped_class_comment}}} @@ -23,9 +20,6 @@ class {{class_name}}({{sub_class_of}}, ModuleType): {{label}}: {{{wrapped_comment}}} {{/attributes}} """ - def __call__(self, *args, **kwargs): - # Dark magic - see last lines of the file. - return {{class_name}}(*args, **kwargs) {{#attributes}} {{^isAssociationUsed}}# *Association not used* @@ -45,12 +39,3 @@ class {{class_name}}({{sub_class_of}}, ModuleType): where this element can be found. """ return { {{#class_origin}}Profile.{{origin}}, {{/class_origin}} } - -# This + inheriting from ModuleType + __call__: -# makes: -# "import {{class_name}}" -# work as well as -# "from {{class_name}} import {{class_name}}". -# You would get a typechecker "not callable" error, but this might be useful for -# backward compatibility. -sys.modules[__name__].__class__ = {{class_name}} \ No newline at end of file diff --git a/modernpython/utils/dataclassconfig.py b/modernpython/utils/dataclassconfig.py index 024840a6..961f0d8a 100644 --- a/modernpython/utils/dataclassconfig.py +++ b/modernpython/utils/dataclassconfig.py @@ -9,4 +9,3 @@ class DataclassConfig: # pylint: disable=too-few-public-methods # By default, with pydantic extra arguments given to a dataclass are silently ignored. # This matches the default behaviour by failing noisily. extra = "forbid" - defer_build = True From 92a3f8f0e0d6f1b8c45c8e2e8c11e4a8b300e0e5 Mon Sep 17 00:00:00 2001 From: Guillaume Roger Date: Fri, 20 Oct 2023 08:38:34 +0200 Subject: [PATCH 5/6] Fix namespace of attributes after review comment Signed-off-by: Guillaume Roger --- modernpython/Base.py | 202 -------------------------------- modernpython/utils/base.py | 31 ++++- modernpython/utils/constants.py | 3 +- modernpython/utils/profile.py | 2 + 4 files changed, 31 insertions(+), 207 deletions(-) delete mode 100644 modernpython/Base.py diff --git a/modernpython/Base.py b/modernpython/Base.py deleted file mode 100644 index 023ec99a..00000000 --- a/modernpython/Base.py +++ /dev/null @@ -1,202 +0,0 @@ -# We follow the CIM naming convention, not python. -# pylint: disable=invalid-name - -""" -Parent element of all CGMES elements -""" -import importlib -from dataclasses import Field, fields -from enum import Enum -from functools import cache, cached_property -from typing import Any, TypeAlias - -# Drop in dataclass replacement, allowing easier json dump and validation in the future. -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 -} - - -@dataclass(config=DataclassConfig) -class Base: - """ - Base Class for CIM. - """ - - @cached_property - def possible_profiles(self) -> set[Profile]: - raise NotImplementedError("Method not implemented because not relevant in Base.") - - @staticmethod - def parse_json_as(attrs: dict[str, Any]) -> "Base": - """ - Given a json, returns the original object. - """ - subclass: str = attrs["__class__"] - - # We want all attributes *except* __class__, and I do not want to modify - # the dict in params with del() or .pop() - data_attrs = {k: v for k, v in attrs.items() if k != "__class__"} - - mod = importlib.import_module(f".{subclass}", package="pycgmes.resources") - # Works because the module and the class have the same name. - return getattr(mod, subclass)(**data_attrs) - - 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 - return attrs - - @cached_property - def resource_name(self) -> str: - """Returns the resource type.""" - return self.__class__.__name__ - - @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 - subclassing this method, keeping the @classmethod decorator. - """ - return cls.__name__ - - def cgmes_attribute_names_in_profile(self, profile: Profile | None) -> set[Field]: - """ - Returns all fields accross the parent tree which are in the profile in parameter. - - Mostly useful during export to find all the attributes relevant to one profile only. - - mRID will not be present as a resource attribute in the rdf, it will appear in the id of a resource, - so is skipped. For instance - - - blah - {here the mRID will not appear} - - - If profile is None, returns all. - """ - return { - f - for f in fields(self) - # The field is defined as a pydantic.Field, not a dataclass.field, - # so access to metadata is a tad different. Furthermore, mypy is confused by extra. - if (profile is None or profile in f.default.extra["in_profiles"]) # type: ignore[union-attr] - if f.name != "mRID" - } - - def cgmes_attributes_in_profile(self, profile: Profile | None) -> dict[str, "CgmesAttributeTypes"]: - """ - Returns all attribute values as a dict: fully qualified name => value. - Fully qualified names is in the form class_name.attribute_name, where class_name is the - (possibly parent) class where the attribute is defined. - - This is used mostly in export, where the attributes need to be written in the form: - 3022308-EL-M01-145-SC3 - 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. - seen_attrs = set() - - # mro contains itself (so parent might be a misnomer) and object, removed wit the [:-1]. - for parent in reversed(self.__class__.__mro__[:-1]): - for f in fields(parent): - shortname = f.name - qualname = f"{parent.apparent_name()}.{shortname}" - 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) - 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()]) - - -CgmesAttributeTypes: TypeAlias = str | int | float | Base | list | None diff --git a/modernpython/utils/base.py b/modernpython/utils/base.py index 3f1cd85b..c307b33b 100644 --- a/modernpython/utils/base.py +++ b/modernpython/utils/base.py @@ -4,6 +4,7 @@ from functools import cached_property from typing import Any, TypeAlias, TypedDict +from pycgmes.utils.constants import NAMESPACES from pydantic.dataclasses import dataclass from .dataclassconfig import DataclassConfig @@ -51,6 +52,13 @@ def resource_name(self) -> str: """Returns the resource type.""" return self.__class__.__name__ + @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: """ @@ -109,12 +117,25 @@ def cgmes_attributes_in_profile(self, profile: BaseProfile | None) -> dict[str, # Wrong profile or already found from a parent. continue else: + # 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), - # base types (e.g. int) do not have extras - namespace=extra.get("namespace", None) - if (extra := getattr(f.default, "extra", None)) - else None, + namespace=namespace, ) seen_attrs.add(shortname) @@ -123,6 +144,8 @@ def cgmes_attributes_in_profile(self, profile: BaseProfile | None) -> dict[str, def __str__(self) -> str: """Returns the string representation of this resource.""" return "\n".join([f"{k}={v}" for k, v in sorted(self.to_dict().items())]) + + CgmesAttributeTypes: TypeAlias = str | int | float | Base | list | None diff --git a/modernpython/utils/constants.py b/modernpython/utils/constants.py index 742771ce..77ef57d4 100644 --- a/modernpython/utils/constants.py +++ b/modernpython/utils/constants.py @@ -1,7 +1,8 @@ # Default namespaces used by CGMES. NAMESPACES = { - "cim": "http://iec.ch/TC57/2013/CIM-schema-cim16#", + "cim": "http://iec.ch/TC57/CIM100#", "entsoe": "http://entsoe.eu/CIM/SchemaExtension/3/1#", "md": "http://iec.ch/TC57/61970-552/ModelDescription/1#", "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "xsd": "http://www.w3.org/2001/XMLSchema#", } diff --git a/modernpython/utils/profile.py b/modernpython/utils/profile.py index cd938b18..fbccb72e 100644 --- a/modernpython/utils/profile.py +++ b/modernpython/utils/profile.py @@ -10,11 +10,13 @@ class BaseProfile(str, Enum): 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. From 532454a993ad9911b287dbd70f6d6a86602680b0 Mon Sep 17 00:00:00 2001 From: Guillaume Roger Date: Fri, 20 Oct 2023 09:40:53 +0200 Subject: [PATCH 6/6] Tell Sonar that namespaces are safe Signed-off-by: Guillaume Roger --- modernpython/utils/constants.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/modernpython/utils/constants.py b/modernpython/utils/constants.py index 77ef57d4..aedb19d5 100644 --- a/modernpython/utils/constants.py +++ b/modernpython/utils/constants.py @@ -1,8 +1,8 @@ # Default namespaces used by CGMES. -NAMESPACES = { - "cim": "http://iec.ch/TC57/CIM100#", - "entsoe": "http://entsoe.eu/CIM/SchemaExtension/3/1#", - "md": "http://iec.ch/TC57/61970-552/ModelDescription/1#", - "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", - "xsd": "http://www.w3.org/2001/XMLSchema#", +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 }